From 153e49274456dc6a3b686f91977dae90e73d0f5e Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 20 May 2024 07:40:32 -0700 Subject: [PATCH 1/3] =?UTF-8?q?Revert=20"Revert=20"Fix=20broken=20HTML=20i?= =?UTF-8?q?nlining=20of=20non=20UTF-8=20decodable=20binary=20data=20f?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 658037358cf4c9813648e3a9b1c70d7e414c87ff. --- packages/next/src/client/app-index.tsx | 21 ++++- .../next/src/server/app-render/app-render.tsx | 2 +- .../server/app-render/use-flight-response.tsx | 53 ++++++++---- packages/next/src/server/base-server.ts | 73 +++++++++++++---- .../lib/incremental-cache/fetch-cache.ts | 5 +- .../incremental-cache/file-system-cache.ts | 80 +++++++++++-------- packages/next/src/server/render-result.ts | 33 +++++++- .../next/src/server/response-cache/types.ts | 30 ++++++- .../next/src/server/response-cache/utils.ts | 24 +++++- .../stream-utils/node-web-streams-helper.ts | 22 +++++ test/e2e/app-dir/binary/app/client.js | 19 +++++ test/e2e/app-dir/binary/app/layout.js | 12 +++ test/e2e/app-dir/binary/app/page.js | 8 ++ test/e2e/app-dir/binary/next.config.js | 6 ++ test/e2e/app-dir/binary/rsc-binary.test.ts | 32 ++++++++ 15 files changed, 344 insertions(+), 76 deletions(-) create mode 100644 test/e2e/app-dir/binary/app/client.js create mode 100644 test/e2e/app-dir/binary/app/layout.js create mode 100644 test/e2e/app-dir/binary/app/page.js create mode 100644 test/e2e/app-dir/binary/next.config.js create mode 100644 test/e2e/app-dir/binary/rsc-binary.test.ts diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index f59ee8507b412..9058c233bf5d1 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -43,7 +43,7 @@ const appElement: HTMLElement | Document | null = document const encoder = new TextEncoder() -let initialServerDataBuffer: string[] | undefined = undefined +let initialServerDataBuffer: (string | Uint8Array)[] | undefined = undefined let initialServerDataWriter: ReadableStreamDefaultController | undefined = undefined let initialServerDataLoaded = false @@ -56,6 +56,7 @@ function nextServerDataCallback( | [isBootStrap: 0] | [isNotBootstrap: 1, responsePartial: string] | [isFormState: 2, formState: any] + | [isBinary: 3, responseBase64Partial: string] ): void { if (seg[0] === 0) { initialServerDataBuffer = [] @@ -70,6 +71,22 @@ function nextServerDataCallback( } } else if (seg[0] === 2) { initialFormStateData = seg[1] + } else if (seg[0] === 3) { + if (!initialServerDataBuffer) + throw new Error('Unexpected server data: missing bootstrap script.') + + // Decode the base64 string back to binary data. + const binaryString = atob(seg[1]) + const decodedChunk = new Uint8Array(binaryString.length) + for (var i = 0; i < binaryString.length; i++) { + decodedChunk[i] = binaryString.charCodeAt(i) + } + + if (initialServerDataWriter) { + initialServerDataWriter.enqueue(decodedChunk) + } else { + initialServerDataBuffer.push(decodedChunk) + } } } @@ -84,7 +101,7 @@ function nextServerDataCallback( function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) { if (initialServerDataBuffer) { initialServerDataBuffer.forEach((val) => { - ctr.enqueue(encoder.encode(val)) + ctr.enqueue(typeof val === 'string' ? encoder.encode(val) : val) }) if (initialServerDataLoaded && !initialServerDataFlushed) { ctr.close() diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index f0672fe11a8dc..cc9af3bb0943f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -398,7 +398,7 @@ function createFlightDataResolver(ctx: AppRenderContext) { // Generate the flight data and as soon as it can, convert it into a string. const promise = generateFlight(ctx) .then(async (result) => ({ - flightData: await result.toUnchunkedString(true), + flightData: await result.toUnchunkedBuffer(true), })) // Otherwise if it errored, return the error. .catch((err) => ({ err })) diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 6069a71aff358..86831a58bd550 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -9,6 +9,7 @@ const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' 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 const flightResponses = new WeakMap, Promise>() const encoder = new TextEncoder() @@ -96,10 +97,8 @@ export function createInlinedDataReadableStream( ? `` + `${scriptStart}self.__next_f.push(${htmlInlinedData})` ) ) } diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index f5cc8c5eb3f27..6b2e8a043aef6 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -13,6 +13,8 @@ import type { ParsedUrlQuery } from 'querystring' import type { RenderOptsPartial as PagesRenderOptsPartial } from './render' import type { RenderOptsPartial as AppRenderOptsPartial } from './app-render/types' import type { + CachedAppPageValue, + CachedPageValue, ResponseCacheBase, ResponseCacheEntry, ResponseGenerator, @@ -2583,16 +2585,29 @@ export default abstract class Server< return null } + if (isAppPath) { + return { + value: { + kind: 'APP_PAGE', + html: result, + headers, + rscData: metadata.flightData, + postponed: metadata.postponed, + status: res.statusCode, + } satisfies CachedAppPageValue, + revalidate: metadata.revalidate, + } + } + // We now have a valid HTML result that we can return to the user. return { value: { kind: 'PAGE', html: result, - pageData: metadata.pageData ?? metadata.flightData, - postponed: metadata.postponed, + pageData: metadata.pageData, headers, - status: isAppPath ? res.statusCode : undefined, - }, + status: res.statusCode, + } satisfies CachedPageValue, revalidate: metadata.revalidate, } } @@ -2705,7 +2720,6 @@ export default abstract class Server< value: { kind: 'PAGE', html: RenderResult.fromStatic(html), - postponed: undefined, status: undefined, headers: undefined, pageData: {}, @@ -2774,7 +2788,7 @@ export default abstract class Server< } const didPostpone = - cacheEntry.value?.kind === 'PAGE' && + cacheEntry.value?.kind === 'APP_PAGE' && typeof cacheEntry.value.postponed === 'string' if ( @@ -2941,7 +2955,11 @@ export default abstract class Server< } else if (isAppPath) { // If the request has a postponed state and it's a resume request we // should error. - if (cachedData.postponed && minimalPostponed) { + if ( + cachedData.kind === 'APP_PAGE' && + cachedData.postponed && + minimalPostponed + ) { throw new Error( 'Invariant: postponed state should not be present on a resume request' ) @@ -2989,7 +3007,11 @@ export default abstract class Server< } // Mark that the request did postpone if this is a data request. - if (cachedData.postponed && isRSCRequest) { + if ( + cachedData.kind === 'APP_PAGE' && + cachedData.postponed && + isRSCRequest + ) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') } @@ -3000,8 +3022,15 @@ export default abstract class Server< if (isDataReq && !isPreviewMode) { // If this is a dynamic RSC request, then stream the response. if (isDynamicRSCRequest) { - if (cachedData.pageData) { - throw new Error('Invariant: Expected pageData to be undefined') + if (cachedData.kind !== 'APP_PAGE') { + console.error({ url: req.url, pathname }, cachedData) + throw new Error( + `Invariant: expected cache data kind of APP_PAGE got ${cachedData.kind}` + ) + } + + if (cachedData.rscData) { + throw new Error('Invariant: Expected rscData to be undefined') } if (cachedData.postponed) { @@ -3020,9 +3049,15 @@ export default abstract class Server< } } - if (typeof cachedData.pageData !== 'string') { + if (cachedData.kind !== 'APP_PAGE') { + throw new Error( + `Invariant: expected cached data to be APP_PAGE got ${cachedData.kind}` + ) + } + + if (!Buffer.isBuffer(cachedData.rscData)) { throw new Error( - `Invariant: expected pageData to be a string, got ${typeof cachedData.pageData}` + `Invariant: expected rscData to be a Buffer, got ${typeof cachedData.rscData}` ) } @@ -3030,7 +3065,7 @@ export default abstract class Server< // data. return { type: 'rsc', - body: RenderResult.fromStatic(cachedData.pageData), + body: RenderResult.fromStatic(cachedData.rscData), revalidate: cacheEntry.revalidate, } } @@ -3041,7 +3076,10 @@ export default abstract class Server< // If there's no postponed state, we should just serve the HTML. This // should also be the case for a resume request because it's completed // as a server render (rather than a static render). - if (!cachedData.postponed || this.minimalMode) { + if ( + !(cachedData.kind === 'APP_PAGE' && cachedData.postponed) || + this.minimalMode + ) { return { type: 'html', body, @@ -3070,7 +3108,7 @@ export default abstract class Server< throw new Error('Invariant: expected a result to be returned') } - if (result.value?.kind !== 'PAGE') { + if (result.value?.kind !== 'APP_PAGE') { throw new Error( `Invariant: expected a page response, got ${result.value?.kind}` ) @@ -3096,6 +3134,11 @@ export default abstract class Server< revalidate: 0, } } else if (isDataReq) { + if (cachedData.kind !== 'PAGE') { + throw new Error( + `Invariant: expected cached data to be PAGE got ${cachedData.kind}` + ) + } return { type: 'json', body: RenderResult.fromStatic(JSON.stringify(cachedData.pageData)), diff --git a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts index 6ecb7048eda90..55d77c0f228fc 100644 --- a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts @@ -109,7 +109,10 @@ export default class FetchCache implements CacheHandler { } // rough estimate of size of cache value return ( - value.html.length + (JSON.stringify(value.pageData)?.length || 0) + value.html.length + + (JSON.stringify( + value.kind === 'APP_PAGE' ? value.rscData : value.pageData + )?.length || 0) ) }, }) diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 2e8400da74e7a..379917ea2ef2a 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -77,7 +77,10 @@ export default class FileSystemCache implements CacheHandler { } // rough estimate of size of cache value return ( - value.html.length + (JSON.stringify(value.pageData)?.length || 0) + value.html.length + + (JSON.stringify( + value.kind === 'APP_PAGE' ? value.rscData : value.pageData + )?.length || 0) ) }, }) @@ -250,23 +253,6 @@ export default class FileSystemCache implements CacheHandler { } } } else { - const pageData = isAppPath - ? await this.fs.readFile( - this.getFilePath( - `${key}${ - this.isAppPPREnabled ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX - }`, - 'app' - ), - 'utf8' - ) - : JSON.parse( - await this.fs.readFile( - this.getFilePath(`${key}${NEXT_DATA_SUFFIX}`, 'pages'), - 'utf8' - ) - ) - let meta: RouteMetadata | undefined if (isAppPath) { @@ -280,16 +266,44 @@ export default class FileSystemCache implements CacheHandler { } catch {} } - data = { - lastModified: mtime.getTime(), - value: { - kind: 'PAGE', - html: fileData, - pageData, - postponed: meta?.postponed, - headers: meta?.headers, - status: meta?.status, - }, + if (isAppPath) { + const rscData = await this.fs.readFile( + this.getFilePath( + `${key}${ + this.isAppPPREnabled ? RSC_PREFETCH_SUFFIX : RSC_SUFFIX + }`, + 'app' + ) + ) + data = { + lastModified: mtime.getTime(), + value: { + kind: 'APP_PAGE', + html: fileData, + rscData, + postponed: meta?.postponed, + headers: meta?.headers, + status: meta?.status, + }, + } + } else { + const pageData = JSON.parse( + await this.fs.readFile( + this.getFilePath(`${key}${NEXT_DATA_SUFFIX}`, 'pages'), + 'utf8' + ) + ) + + data = { + lastModified: mtime.getTime(), + value: { + kind: 'PAGE', + html: fileData, + pageData, + headers: meta?.headers, + status: meta?.status, + }, + } } } @@ -301,7 +315,7 @@ export default class FileSystemCache implements CacheHandler { } } - if (data?.value?.kind === 'PAGE') { + if (data?.value?.kind === 'APP_PAGE' || data?.value?.kind === 'PAGE') { let cacheTags: undefined | string[] const tagsHeader = data.value.headers?.[NEXT_CACHE_TAGS_HEADER] @@ -385,8 +399,8 @@ export default class FileSystemCache implements CacheHandler { return } - if (data?.kind === 'PAGE') { - const isAppPath = typeof data.pageData === 'string' + if (data?.kind === 'PAGE' || data?.kind === 'APP_PAGE') { + const isAppPath = 'rscData' in data const htmlPath = this.getFilePath( `${key}.html`, isAppPath ? 'app' : 'pages' @@ -405,14 +419,14 @@ export default class FileSystemCache implements CacheHandler { }`, isAppPath ? 'app' : 'pages' ), - isAppPath ? data.pageData : JSON.stringify(data.pageData) + isAppPath ? data.rscData : JSON.stringify(data.pageData) ) if (data.headers || data.status) { const meta: RouteMetadata = { headers: data.headers, status: data.status, - postponed: data.postponed, + postponed: isAppPath ? data.postponed : undefined, } await this.fs.writeFile( diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 57bd89695dbfb..448a0431795e6 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -4,7 +4,9 @@ import type { FetchMetrics } from './base-http' import { chainStreams, + streamFromBuffer, streamFromString, + streamToBuffer, streamToString, } from './stream-utils/node-web-streams-helper' import { isAbortError, pipeToNodeResponse } from './pipe-readable' @@ -12,7 +14,7 @@ import { isAbortError, pipeToNodeResponse } from './pipe-readable' type ContentTypeOption = string | undefined export type AppPageRenderResultMetadata = { - flightData?: string + flightData?: Buffer revalidate?: Revalidate staticBailoutInfo?: { stack?: string @@ -50,6 +52,7 @@ export type RenderResultResponse = | ReadableStream[] | ReadableStream | string + | Buffer | null export type RenderResultOptions< @@ -89,7 +92,7 @@ export default class RenderResult< * @param value the static response value * @returns a new RenderResult instance */ - public static fromStatic(value: string) { + public static fromStatic(value: string | Buffer) { return new RenderResult(value, { metadata: {} }) } @@ -125,6 +128,26 @@ export default class RenderResult< return typeof this.response !== 'string' } + public toUnchunkedBuffer(stream?: false): Buffer + public toUnchunkedBuffer(stream: true): Promise + public toUnchunkedBuffer(stream = false): Promise | Buffer { + if (this.response === null) { + throw new Error('Invariant: null responses cannot be unchunked') + } + + if (typeof this.response !== 'string') { + if (!stream) { + throw new Error( + 'Invariant: dynamic responses cannot be unchunked. This is a bug in Next.js' + ) + } + + return streamToBuffer(this.readable) + } + + return Buffer.from(this.response) + } + /** * Returns the response if it is a string. If the page was dynamic, this will * return a promise if the `stream` option is true, or it will throw an error. @@ -164,6 +187,10 @@ export default class RenderResult< throw new Error('Invariant: static responses cannot be streamed') } + if (Buffer.isBuffer(this.response)) { + return streamFromBuffer(this.response) + } + // If the response is an array of streams, then chain them together. if (Array.isArray(this.response)) { return chainStreams(...this.response) @@ -191,6 +218,8 @@ export default class RenderResult< responses = [streamFromString(this.response)] } else if (Array.isArray(this.response)) { responses = this.response + } else if (Buffer.isBuffer(this.response)) { + responses = [streamFromBuffer(this.response)] } else { responses = [this.response] } diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 4f098edf9932c..e8492bb3f4a0e 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -40,12 +40,22 @@ export interface CachedRedirectValue { props: Object } -interface CachedPageValue { - kind: 'PAGE' +export interface CachedAppPageValue { + kind: 'APP_PAGE' // this needs to be a RenderResult so since renderResponse // expects that type instead of a string html: RenderResult + rscData: Buffer | undefined + status: number | undefined postponed: string | undefined + headers: OutgoingHttpHeaders | undefined +} + +export interface CachedPageValue { + kind: 'PAGE' + // this needs to be a RenderResult so since renderResponse + // expects that type instead of a string + html: RenderResult pageData: Object status: number | undefined headers: OutgoingHttpHeaders | undefined @@ -69,13 +79,23 @@ export interface CachedImageValue { isStale?: boolean } -interface IncrementalCachedPageValue { +export interface IncrementalCachedAppPageValue { + kind: 'APP_PAGE' + // this needs to be a string since the cache expects to store + // the string value + html: string + rscData: Buffer | undefined + headers: OutgoingHttpHeaders | undefined + postponed: string | undefined + status: number | undefined +} + +export interface IncrementalCachedPageValue { kind: 'PAGE' // this needs to be a string since the cache expects to store // the string value html: string pageData: Object - postponed: string | undefined headers: OutgoingHttpHeaders | undefined status: number | undefined } @@ -92,6 +112,7 @@ export type IncrementalCacheEntry = { export type IncrementalCacheValue = | CachedRedirectValue | IncrementalCachedPageValue + | IncrementalCachedAppPageValue | CachedImageValue | CachedFetchValue | CachedRouteValue @@ -99,6 +120,7 @@ export type IncrementalCacheValue = export type ResponseCacheValue = | CachedRedirectValue | CachedPageValue + | CachedAppPageValue | CachedImageValue | CachedRouteValue diff --git a/packages/next/src/server/response-cache/utils.ts b/packages/next/src/server/response-cache/utils.ts index 174d6fa619a4a..18ec64c1dd052 100644 --- a/packages/next/src/server/response-cache/utils.ts +++ b/packages/next/src/server/response-cache/utils.ts @@ -12,12 +12,20 @@ export async function fromResponseCacheEntry( ? { kind: 'PAGE', html: await cacheEntry.value.html.toUnchunkedString(true), - postponed: cacheEntry.value.postponed, pageData: cacheEntry.value.pageData, headers: cacheEntry.value.headers, status: cacheEntry.value.status, } - : cacheEntry.value, + : cacheEntry.value?.kind === 'APP_PAGE' + ? { + kind: 'APP_PAGE', + html: await cacheEntry.value.html.toUnchunkedString(true), + postponed: cacheEntry.value.postponed, + rscData: cacheEntry.value.rscData, + headers: cacheEntry.value.headers, + status: cacheEntry.value.status, + } + : cacheEntry.value, } } @@ -42,10 +50,18 @@ export async function toResponseCacheEntry( kind: 'PAGE', html: RenderResult.fromStatic(response.value.html), pageData: response.value.pageData, - postponed: response.value.postponed, headers: response.value.headers, status: response.value.status, } - : response.value, + : response.value?.kind === 'APP_PAGE' + ? { + kind: 'APP_PAGE', + html: RenderResult.fromStatic(response.value.html), + rscData: response.value.rscData, + headers: response.value.headers, + status: response.value.status, + postponed: response.value.postponed, + } + : response.value, } } diff --git a/packages/next/src/server/stream-utils/node-web-streams-helper.ts b/packages/next/src/server/stream-utils/node-web-streams-helper.ts index a3d4ae8b924ee..8b051257c5868 100644 --- a/packages/next/src/server/stream-utils/node-web-streams-helper.ts +++ b/packages/next/src/server/stream-utils/node-web-streams-helper.ts @@ -73,6 +73,28 @@ export function streamFromString(str: string): ReadableStream { }) } +export function streamFromBuffer(chunk: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(chunk) + controller.close() + }, + }) +} + +export async function streamToBuffer( + stream: ReadableStream +): Promise { + const buffers: Buffer[] = [] + + // @ts-expect-error TypeScript gets this wrong (https://nodejs.org/api/webstreams.html#async-iteration) + for await (const chunk of stream) { + buffers.push(chunk) + } + + return Buffer.concat(buffers) +} + export async function streamToString( stream: ReadableStream ): Promise { diff --git a/test/e2e/app-dir/binary/app/client.js b/test/e2e/app-dir/binary/app/client.js new file mode 100644 index 0000000000000..37f29ac669dc4 --- /dev/null +++ b/test/e2e/app-dir/binary/app/client.js @@ -0,0 +1,19 @@ +'use client' + +import { useEffect, useState } from 'react' + +export function Client({ binary, arbitrary }) { + const [hydrated, setHydrated] = useState(false) + + useEffect(() => { + setHydrated(true) + }, []) + + return ( + <> +
utf8 binary: {new TextDecoder().decode(binary)}
+
arbitrary binary: {String(arbitrary)}
+
hydrated: {String(hydrated)}
+ + ) +} diff --git a/test/e2e/app-dir/binary/app/layout.js b/test/e2e/app-dir/binary/app/layout.js new file mode 100644 index 0000000000000..8525f5f8c0b2a --- /dev/null +++ b/test/e2e/app-dir/binary/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/binary/app/page.js b/test/e2e/app-dir/binary/app/page.js new file mode 100644 index 0000000000000..5bbd22eedea98 --- /dev/null +++ b/test/e2e/app-dir/binary/app/page.js @@ -0,0 +1,8 @@ +import { Client } from './client' + +export default function Page() { + const binaryData = new Uint8Array([104, 101, 108, 108, 111]) + const nonUtf8BinaryData = new Uint8Array([0xff, 0, 1, 2, 3]) + + return +} diff --git a/test/e2e/app-dir/binary/next.config.js b/test/e2e/app-dir/binary/next.config.js new file mode 100644 index 0000000000000..5b7ed7e24f002 --- /dev/null +++ b/test/e2e/app-dir/binary/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + experimental: { + // This ensures that we're running the experimental React. + taint: true, + }, +} diff --git a/test/e2e/app-dir/binary/rsc-binary.test.ts b/test/e2e/app-dir/binary/rsc-binary.test.ts new file mode 100644 index 0000000000000..ab400e325d5ce --- /dev/null +++ b/test/e2e/app-dir/binary/rsc-binary.test.ts @@ -0,0 +1,32 @@ +import { nextTestSetup } from 'e2e-utils' +import { check } from 'next-test-utils' + +describe('RSC binary serialization', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + dependencies: { + react: '19.0.0-beta-04b058868c-20240508', + 'react-dom': '19.0.0-beta-04b058868c-20240508', + 'server-only': 'latest', + }, + }) + if (skipped) return + + afterEach(async () => { + await next.stop() + }) + + it('should correctly encode/decode binaries and hydrate', async function () { + const browser = await next.browser('/') + await check(async () => { + const content = await browser.elementByCss('body').text() + + return content.includes('utf8 binary: hello') && + content.includes('arbitrary binary: 255,0,1,2,3') && + content.includes('hydrated: true') + ? 'success' + : 'fail' + }, 'success') + }) +}) From beec5519b2320b0fba9863748f88a1d3e58f44b6 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 4 Jun 2024 16:10:57 -0700 Subject: [PATCH 2/3] fixes --- .../next/src/server/app-render/app-render.tsx | 13 ++-- packages/next/src/server/base-server.ts | 77 +++++++------------ .../incremental-cache/file-system-cache.ts | 4 +- 3 files changed, 33 insertions(+), 61 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 1946074e1a594..16605771bb355 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -10,7 +10,7 @@ import type { } from './types' import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' import type { RequestStore } from '../../client/components/request-async-storage.external' -import type { NextParsedUrlQuery } from '../request-meta' +import { getRequestMeta, type NextParsedUrlQuery } from '../request-meta' import type { LoaderTree } from '../lib/app-dir-module' import type { AppPageModule } from '../future/route-modules/app-page/module' import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' @@ -37,10 +37,8 @@ import { import { canSegmentBeOverridden } from '../../client/components/match-segments' import { stripInternalQueries } from '../internal-utils' import { - NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_STATE_TREE, NEXT_URL, - RSC_HEADER, } from '../../client/components/app-router-headers' import { createMetadataComponents } from '../../lib/metadata/metadata' import { RequestAsyncStorageWrapper } from '../async-storage/request-async-storage-wrapper' @@ -800,12 +798,11 @@ async function renderToHTMLOrFlightImpl( query = { ...query } stripInternalQueries(query) - const isRSCRequest = req.headers[RSC_HEADER.toLowerCase()] !== undefined - - const isPrefetchRSCRequest = - isRSCRequest && - req.headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined + const isRSCRequest = Boolean(getRequestMeta(req, 'isRSCRequest')) + const isPrefetchRSCRequest = Boolean( + getRequestMeta(req, 'isPrefetchRSCRequest') + ) /** * Router state provided from the client-side router. Used to handle rendering * from the common layout down. This value will be undefined if the request diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 267e3f5696faf..78107cc425553 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2599,6 +2599,7 @@ export default abstract class Server< return null } + // We now have a valid HTML result that we can return to the user. if (isAppPath) { return { value: { @@ -2613,14 +2614,13 @@ export default abstract class Server< } } - // We now have a valid HTML result that we can return to the user. return { value: { kind: 'PAGE', html: result, - pageData: metadata.pageData, + pageData: metadata.pageData ?? metadata.flightData, headers, - status: res.statusCode, + status: isAppPath ? res.statusCode : undefined, } satisfies CachedPageValue, revalidate: metadata.revalidate, } @@ -2895,9 +2895,23 @@ export default abstract class Server< // and the revalidate options. const onCacheEntry = getRequestMeta(req, 'onCacheEntry') if (onCacheEntry) { - const finished = await onCacheEntry(cacheEntry, { - url: getRequestMeta(req, 'initURL'), - }) + const finished = await onCacheEntry( + { + ...cacheEntry, + // TODO: remove this when upstream doesn't + // always expect this value to be "PAGE" + value: { + ...cacheEntry.value, + kind: + cacheEntry.value?.kind === 'APP_PAGE' + ? 'PAGE' + : cacheEntry.value?.kind, + }, + }, + { + url: getRequestMeta(req, 'initURL'), + } + ) if (finished) { // TODO: maybe we have to end the request? return null @@ -2966,14 +2980,10 @@ export default abstract class Server< }) ) return null - } else if (isAppPath) { + } else if (cachedData.kind === 'APP_PAGE') { // If the request has a postponed state and it's a resume request we // should error. - if ( - cachedData.kind === 'APP_PAGE' && - cachedData.postponed && - minimalPostponed - ) { + if (cachedData.postponed && minimalPostponed) { throw new Error( 'Invariant: postponed state should not be present on a resume request' ) @@ -3021,11 +3031,7 @@ export default abstract class Server< } // Mark that the request did postpone if this is a data request. - if ( - cachedData.kind === 'APP_PAGE' && - cachedData.postponed && - isRSCRequest - ) { + if (cachedData.postponed && isRSCRequest) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') } @@ -3035,18 +3041,7 @@ export default abstract class Server< // return the generated payload if (isRSCRequest && !isPreviewMode) { // If this is a dynamic RSC request, then stream the response. - if (isDynamicRSCRequest) { - if (cachedData.kind !== 'APP_PAGE') { - console.error({ url: req.url, pathname }, cachedData) - throw new Error( - `Invariant: expected cache data kind of APP_PAGE got ${cachedData.kind}` - ) - } - - if (cachedData.rscData) { - throw new Error('Invariant: Expected rscData to be undefined') - } - + if (typeof cachedData.rscData === 'undefined') { if (cachedData.postponed) { throw new Error('Invariant: Expected postponed to be undefined') } @@ -3063,18 +3058,6 @@ export default abstract class Server< } } - if (cachedData.kind !== 'APP_PAGE') { - throw new Error( - `Invariant: expected cached data to be APP_PAGE got ${cachedData.kind}` - ) - } - - if (!Buffer.isBuffer(cachedData.rscData)) { - throw new Error( - `Invariant: expected rscData to be a Buffer, got ${typeof cachedData.rscData}` - ) - } - // As this isn't a prefetch request, we should serve the static flight // data. return { @@ -3090,10 +3073,7 @@ export default abstract class Server< // If there's no postponed state, we should just serve the HTML. This // should also be the case for a resume request because it's completed // as a server render (rather than a static render). - if ( - !(cachedData.kind === 'APP_PAGE' && cachedData.postponed) || - this.minimalMode - ) { + if (!cachedData.postponed || this.minimalMode) { return { type: 'html', body, @@ -3122,7 +3102,7 @@ export default abstract class Server< throw new Error('Invariant: expected a result to be returned') } - if (result.value?.kind !== 'APP_PAGE') { + if (result.value?.kind !== 'PAGE') { throw new Error( `Invariant: expected a page response, got ${result.value?.kind}` ) @@ -3148,11 +3128,6 @@ export default abstract class Server< revalidate: 0, } } else if (isNextDataRequest) { - if (cachedData.kind !== 'PAGE') { - throw new Error( - `Invariant: expected cached data to be PAGE got ${cachedData.kind}` - ) - } return { type: 'json', body: RenderResult.fromStatic(JSON.stringify(cachedData.pageData)), diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 379917ea2ef2a..2f294343daade 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -400,7 +400,7 @@ export default class FileSystemCache implements CacheHandler { } if (data?.kind === 'PAGE' || data?.kind === 'APP_PAGE') { - const isAppPath = 'rscData' in data + const isAppPath = data.kind === 'APP_PAGE' const htmlPath = this.getFilePath( `${key}.html`, isAppPath ? 'app' : 'pages' @@ -422,7 +422,7 @@ export default class FileSystemCache implements CacheHandler { isAppPath ? data.rscData : JSON.stringify(data.pageData) ) - if (data.headers || data.status) { + if (data.headers || data.status || (isAppPath && data.postponed)) { const meta: RouteMetadata = { headers: data.headers, status: data.status, From 4421ed54501513c37751924ba91a387f3a76ac78 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 4 Jun 2024 16:38:58 -0700 Subject: [PATCH 3/3] fix case --- packages/next/src/server/base-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index a6293141595b8..7dbd10d9fece5 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -3104,7 +3104,7 @@ export default abstract class Server< throw new Error('Invariant: expected a result to be returned') } - if (result.value?.kind !== 'PAGE') { + if (result.value?.kind !== 'APP_PAGE') { throw new Error( `Invariant: expected a page response, got ${result.value?.kind}` )