From 7af075b3db967e706dae578cc958394c388fa5bc Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 May 2024 00:40:40 -0700 Subject: [PATCH 1/6] support inlining binary flight payload --- packages/next/src/client/app-index.tsx | 14 ++++- .../server/app-render/use-flight-response.tsx | 61 ++++++++++++++----- test/e2e/app-dir/actions/app/binary/action.js | 7 +++ test/e2e/app-dir/actions/app/binary/client.js | 44 +++++++++++++ test/e2e/app-dir/actions/app/binary/page.js | 30 +++++++++ test/e2e/app-dir/actions/next.config.js | 3 + 6 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 test/e2e/app-dir/actions/app/binary/action.js create mode 100644 test/e2e/app-dir/actions/app/binary/client.js create mode 100644 test/e2e/app-dir/actions/app/binary/page.js diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index f59ee8507b412..0fe072089535b 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, responsePartial: Uint8Array] ): void { if (seg[0] === 0) { initialServerDataBuffer = [] @@ -70,6 +71,15 @@ 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.') + + if (initialServerDataWriter) { + initialServerDataWriter.enqueue(seg[1]) + } else { + initialServerDataBuffer.push(seg[1]) + } } } @@ -84,7 +94,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/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 6069a71aff358..82b87a74bcb01 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() @@ -78,6 +79,25 @@ export async function flightRenderComplete( } } +const decoder = new TextDecoder('utf-8', { fatal: true }) + +/** + * This function will attempt to decode a Uint8Array as a UTF-8 string. If the + * data is not valid UTF-8 it will return null. + * + * @param value A Uint8Array that can contain arbitrary data. + */ +function tryDecodeAsUtf8String( + value: Uint8Array, + stream: boolean +): string | null { + try { + return decoder.decode(value, { stream }) + } catch { + return null + } +} + /** * Creates a ReadableStream provides inline script tag chunks for writing hydration * data to the client outside the React render itself. @@ -96,9 +116,6 @@ export function createInlinedDataReadableStream( ? `` + `${scriptStart}self.__next_f.push(${htmlInlinedData})` ) ) } diff --git a/test/e2e/app-dir/actions/app/binary/action.js b/test/e2e/app-dir/actions/app/binary/action.js new file mode 100644 index 0000000000000..a2e7d3c64e047 --- /dev/null +++ b/test/e2e/app-dir/actions/app/binary/action.js @@ -0,0 +1,7 @@ +'use server' + +export async function* gen() { + yield 'string' + yield new Uint8Array([104, 101, 108, 108, 111]) + yield 'result' +} diff --git a/test/e2e/app-dir/actions/app/binary/client.js b/test/e2e/app-dir/actions/app/binary/client.js new file mode 100644 index 0000000000000..a2bcce1a43d70 --- /dev/null +++ b/test/e2e/app-dir/actions/app/binary/client.js @@ -0,0 +1,44 @@ +'use client' + +import { useState } from 'react' +import { gen } from './action' + +export function Client({ data, arbitrary, action }) { + const [payload, setPayload] = useState('') + + return ( + <> +
prop: {new TextDecoder().decode(data)}
+
arbitrary binary: {String(arbitrary)}
+
action payload: {payload}
+ + + + ) +} diff --git a/test/e2e/app-dir/actions/app/binary/page.js b/test/e2e/app-dir/actions/app/binary/page.js new file mode 100644 index 0000000000000..3a1784238660e --- /dev/null +++ b/test/e2e/app-dir/actions/app/binary/page.js @@ -0,0 +1,30 @@ +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, 4]) + + return ( + setTimeout(resolve, 500)) + controller.enqueue(new Uint8Array([108, 108, 111])) + await new Promise((resolve) => setTimeout(resolve, 500)) + controller.close() + }, + }), + } + }} + /> + ) +} diff --git a/test/e2e/app-dir/actions/next.config.js b/test/e2e/app-dir/actions/next.config.js index 903009cede352..44a48b74cedd4 100644 --- a/test/e2e/app-dir/actions/next.config.js +++ b/test/e2e/app-dir/actions/next.config.js @@ -4,4 +4,7 @@ module.exports = { logging: { fetches: {}, }, + experimental: { + taint: true, + }, } From 596f012a1eb500dc6b9571c7719f469c2103b2ce Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Sun, 12 May 2024 09:58:50 -0700 Subject: [PATCH 2/6] improve inlining --- packages/next/src/client/app-index.tsx | 13 ++++++++++--- .../src/server/app-render/use-flight-response.tsx | 9 +++++++-- test/e2e/app-dir/actions/app/binary/action.js | 7 ------- test/e2e/app-dir/actions/app/binary/client.js | 10 ---------- test/e2e/app-dir/actions/app/binary/page.js | 2 +- 5 files changed, 18 insertions(+), 23 deletions(-) delete mode 100644 test/e2e/app-dir/actions/app/binary/action.js diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index 0fe072089535b..9058c233bf5d1 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -56,7 +56,7 @@ function nextServerDataCallback( | [isBootStrap: 0] | [isNotBootstrap: 1, responsePartial: string] | [isFormState: 2, formState: any] - | [isBinary: 3, responsePartial: Uint8Array] + | [isBinary: 3, responseBase64Partial: string] ): void { if (seg[0] === 0) { initialServerDataBuffer = [] @@ -75,10 +75,17 @@ function nextServerDataCallback( 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(seg[1]) + initialServerDataWriter.enqueue(decodedChunk) } else { - initialServerDataBuffer.push(seg[1]) + initialServerDataBuffer.push(decodedChunk) } } } 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 82b87a74bcb01..1c4df91ffe0b8 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -138,7 +138,6 @@ export function createInlinedDataReadableStream( if (decoded === null) { // The chunk cannot be decoded as valid UTF-8 string as it might // have arbitrary binary data. - // Instead let's inline it in base64 and decode it in place. writeFlightDataInstruction(controller, startScriptTag, value) } else if (decoded.length) { writeFlightDataInstruction(controller, startScriptTag, decoded) @@ -187,8 +186,14 @@ function writeFlightDataInstruction( 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 = `[${INLINE_FLIGHT_PAYLOAD_BINARY},Uint8Array.from(atob("${base64}"),s=>s.codePointAt(0))]` + htmlInlinedData = htmlEscapeJsonString( + JSON.stringify([INLINE_FLIGHT_PAYLOAD_BINARY, base64]) + ) } controller.enqueue( diff --git a/test/e2e/app-dir/actions/app/binary/action.js b/test/e2e/app-dir/actions/app/binary/action.js deleted file mode 100644 index a2e7d3c64e047..0000000000000 --- a/test/e2e/app-dir/actions/app/binary/action.js +++ /dev/null @@ -1,7 +0,0 @@ -'use server' - -export async function* gen() { - yield 'string' - yield new Uint8Array([104, 101, 108, 108, 111]) - yield 'result' -} diff --git a/test/e2e/app-dir/actions/app/binary/client.js b/test/e2e/app-dir/actions/app/binary/client.js index a2bcce1a43d70..472c0ce1cf0ea 100644 --- a/test/e2e/app-dir/actions/app/binary/client.js +++ b/test/e2e/app-dir/actions/app/binary/client.js @@ -1,7 +1,6 @@ 'use client' import { useState } from 'react' -import { gen } from './action' export function Client({ data, arbitrary, action }) { const [payload, setPayload] = useState('') @@ -30,15 +29,6 @@ export function Client({ data, arbitrary, action }) { > send - ) } diff --git a/test/e2e/app-dir/actions/app/binary/page.js b/test/e2e/app-dir/actions/app/binary/page.js index 3a1784238660e..fb874914d360b 100644 --- a/test/e2e/app-dir/actions/app/binary/page.js +++ b/test/e2e/app-dir/actions/app/binary/page.js @@ -2,7 +2,7 @@ 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, 4]) + const nonUtf8BinaryData = new Uint8Array([0xff, 0, 1, 2, 3]) return ( Date: Sun, 12 May 2024 12:04:42 -0700 Subject: [PATCH 3/6] add test --- test/e2e/app-dir/actions/app/binary/client.js | 34 ------------------- test/e2e/app-dir/actions/app/binary/page.js | 30 ---------------- test/e2e/app-dir/actions/next.config.js | 3 -- 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 +++++++++++++++++ 8 files changed, 77 insertions(+), 67 deletions(-) delete mode 100644 test/e2e/app-dir/actions/app/binary/client.js delete mode 100644 test/e2e/app-dir/actions/app/binary/page.js 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/test/e2e/app-dir/actions/app/binary/client.js b/test/e2e/app-dir/actions/app/binary/client.js deleted file mode 100644 index 472c0ce1cf0ea..0000000000000 --- a/test/e2e/app-dir/actions/app/binary/client.js +++ /dev/null @@ -1,34 +0,0 @@ -'use client' - -import { useState } from 'react' - -export function Client({ data, arbitrary, action }) { - const [payload, setPayload] = useState('') - - return ( - <> -
prop: {new TextDecoder().decode(data)}
-
arbitrary binary: {String(arbitrary)}
-
action payload: {payload}
- - - ) -} diff --git a/test/e2e/app-dir/actions/app/binary/page.js b/test/e2e/app-dir/actions/app/binary/page.js deleted file mode 100644 index fb874914d360b..0000000000000 --- a/test/e2e/app-dir/actions/app/binary/page.js +++ /dev/null @@ -1,30 +0,0 @@ -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 ( - setTimeout(resolve, 500)) - controller.enqueue(new Uint8Array([108, 108, 111])) - await new Promise((resolve) => setTimeout(resolve, 500)) - controller.close() - }, - }), - } - }} - /> - ) -} diff --git a/test/e2e/app-dir/actions/next.config.js b/test/e2e/app-dir/actions/next.config.js index 44a48b74cedd4..903009cede352 100644 --- a/test/e2e/app-dir/actions/next.config.js +++ b/test/e2e/app-dir/actions/next.config.js @@ -4,7 +4,4 @@ module.exports = { logging: { fetches: {}, }, - experimental: { - taint: true, - }, } 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..4f29a0a3d5942 --- /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-4508873393-20240430', + 'react-dom': '19.0.0-beta-4508873393-20240430', + '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 15160d0a8c01a17b7c76ea6d332cf11af5f50dae Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 13 May 2024 01:06:11 -0700 Subject: [PATCH 4/6] fix inlining decoding function --- .../server/app-render/use-flight-response.tsx | 33 ++++++------------- .../stream-utils/node-web-streams-helper.ts | 7 +++- 2 files changed, 16 insertions(+), 24 deletions(-) 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 1c4df91ffe0b8..86831a58bd550 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -79,25 +79,6 @@ export async function flightRenderComplete( } } -const decoder = new TextDecoder('utf-8', { fatal: true }) - -/** - * This function will attempt to decode a Uint8Array as a UTF-8 string. If the - * data is not valid UTF-8 it will return null. - * - * @param value A Uint8Array that can contain arbitrary data. - */ -function tryDecodeAsUtf8String( - value: Uint8Array, - stream: boolean -): string | null { - try { - return decoder.decode(value, { stream }) - } catch { - return null - } -} - /** * Creates a ReadableStream provides inline script tag chunks for writing hydration * data to the client outside the React render itself. @@ -117,6 +98,7 @@ export function createInlinedDataReadableStream( : '