From 09b18d199b8dabf0a319b083773234837a4ed497 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 28 Jun 2024 17:38:59 -0400 Subject: [PATCH 1/3] Allow Flight Client to be implemented by parsing pass-through string chunks It can be efficient to accept raw string chunks to pass through a stream instead of encoding them into a binary copy first. However, to avoid having to deal with encoding them back to binary from the parser itself, this helper is limited to dealing with unsplit and unconcattened strings so that we can make some assumptions. This is mainly intended for use for pass-through within the same memory. --- .../react-client/src/ReactFlightClient.js | 158 +++++++++++++++++- scripts/error-codes/codes.json | 4 +- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 7d421f0422b56..3cb918d6c8644 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2121,7 +2121,7 @@ function resolveTypedArray( resolveBuffer(response, id, view); } -function processFullRow( +function processFullBinaryRow( response: Response, id: number, tag: number, @@ -2183,6 +2183,15 @@ function processFullRow( row += readPartialStringChunk(stringDecoder, buffer[i]); } row += readFinalStringChunk(stringDecoder, chunk); + processFullStringRow(response, id, tag, row); +} + +function processFullStringRow( + response: Response, + id: number, + tag: number, + row: string, +): void { switch (tag) { case 73 /* "I" */: { resolveModule(response, id, row); @@ -2385,7 +2394,7 @@ export function processBinaryChunk( // We found the last chunk of the row const length = lastIdx - i; const lastChunk = new Uint8Array(chunk.buffer, offset, length); - processFullRow(response, rowID, rowTag, buffer, lastChunk); + processFullBinaryRow(response, rowID, rowTag, buffer, lastChunk); // Reset state machine for a new row i = lastIdx; if (rowState === ROW_CHUNK_BY_NEWLINE) { @@ -2415,6 +2424,151 @@ export function processBinaryChunk( response._rowLength = rowLength; } +export function processStringChunk(response: Response, chunk: string): void { + // This is a fork of processBinaryChunk that takes a string as input. + // This can't be just any binary chunk coverted to a string. It needs to be + // in the same offsets given from the Flight Server. E.g. if it's shifted by + // one byte then it won't line up to the UCS-2 encoding. It also needs to + // be valid Unicode. Also binary chunks cannot use this even if they're + // value Unicode. Large strings are encoded as binary and cannot be passed + // here. Basically, only if Flight Server gave you this string as a chunk, + // you can use it here. + let i = 0; + let rowState = response._rowState; + let rowID = response._rowID; + let rowTag = response._rowTag; + let rowLength = response._rowLength; + const buffer = response._buffer; + const chunkLength = chunk.length; + while (i < chunkLength) { + let lastIdx = -1; + switch (rowState) { + case ROW_ID: { + const byte = chunk.charCodeAt(i++); + if (byte === 58 /* ":" */) { + // Finished the rowID, next we'll parse the tag. + rowState = ROW_TAG; + } else { + rowID = (rowID << 4) | (byte > 96 ? byte - 87 : byte - 48); + } + continue; + } + case ROW_TAG: { + const resolvedRowTag = chunk.charCodeAt(i); + if ( + resolvedRowTag === 84 /* "T" */ || + (enableBinaryFlight && + (resolvedRowTag === 65 /* "A" */ || + resolvedRowTag === 79 /* "O" */ || + resolvedRowTag === 111 /* "o" */ || + resolvedRowTag === 85 /* "U" */ || + resolvedRowTag === 83 /* "S" */ || + resolvedRowTag === 115 /* "s" */ || + resolvedRowTag === 76 /* "L" */ || + resolvedRowTag === 108 /* "l" */ || + resolvedRowTag === 71 /* "G" */ || + resolvedRowTag === 103 /* "g" */ || + resolvedRowTag === 77 /* "M" */ || + resolvedRowTag === 109 /* "m" */ || + resolvedRowTag === 86)) /* "V" */ + ) { + rowTag = resolvedRowTag; + rowState = ROW_LENGTH; + i++; + } else if ( + (resolvedRowTag > 64 && resolvedRowTag < 91) /* "A"-"Z" */ || + resolvedRowTag === 114 /* "r" */ || + resolvedRowTag === 120 /* "x" */ + ) { + rowTag = resolvedRowTag; + rowState = ROW_CHUNK_BY_NEWLINE; + i++; + } else { + rowTag = 0; + rowState = ROW_CHUNK_BY_NEWLINE; + // This was an unknown tag so it was probably part of the data. + } + continue; + } + case ROW_LENGTH: { + const byte = chunk.charCodeAt(i++); + if (byte === 44 /* "," */) { + // Finished the rowLength, next we'll buffer up to that length. + rowState = ROW_CHUNK_BY_LENGTH; + } else { + rowLength = (rowLength << 4) | (byte > 96 ? byte - 87 : byte - 48); + } + continue; + } + case ROW_CHUNK_BY_NEWLINE: { + // We're looking for a newline + lastIdx = chunk.indexOf('\n', i); + break; + } + case ROW_CHUNK_BY_LENGTH: { + if (rowTag !== 84) { + throw new Error( + 'Binary RSC chunks cannot be encoded as strings. ' + + 'This is a bug in the wiring of the React streams.', + ); + } + // For a large string by length, we don't know how many unicode characters + // we are looking for but we can assume that the raw string will be its own + // chunk. We add extra validation that the length is at least within the + // possible byte range it could possibly be to catch mistakes. + if (rowLength < chunk.length || chunk.length > rowLength * 3) { + throw new Error( + 'String chunks need to be passed in their original shape. ' + + 'Not split into smaller string chunks. ' + + 'This is a bug in the wiring of the React streams.', + ); + } + lastIdx = chunk.length; + break; + } + } + if (lastIdx > -1) { + // We found the last chunk of the row + if (buffer.length > 0) { + // If we had a buffer already, it means that this chunk was split up into + // binary chunks preceeding it. + throw new Error( + 'String chunks need to be passed in their original shape. ' + + 'Not split into smaller string chunks. ' + + 'This is a bug in the wiring of the React streams.', + ); + } + const lastChunk = chunk.slice(i, lastIdx); + processFullStringRow(response, rowID, rowTag, lastChunk); + // Reset state machine for a new row + i = lastIdx; + if (rowState === ROW_CHUNK_BY_NEWLINE) { + // If we're trailing by a newline we need to skip it. + i++; + } + rowState = ROW_ID; + rowTag = 0; + rowID = 0; + rowLength = 0; + buffer.length = 0; + } else if (chunk.length !== i) { + // The rest of this row is in a future chunk. We only support passing the + // string from chunks in their entirety. Not split up into smaller string chunks. + // We could support this by buffering them but we shouldn't need to for + // this use case. + throw new Error( + 'String chunks need to be passed in their original shape. ' + + 'Not split into smaller string chunks. ' + + 'This is a bug in the wiring of the React streams.', + ); + } + } + response._rowState = rowState; + response._rowID = rowID; + response._rowTag = rowTag; + response._rowLength = rowLength; +} + function parseModel(response: Response, json: UninitializedModel): T { return JSON.parse(json, response._fromJSON); } diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 46600256b0279..088bd5b33bb56 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -523,5 +523,7 @@ "535": "renderToMarkup should not have emitted Server References. This is a bug in React.", "536": "Cannot pass ref in renderToMarkup because they will never be hydrated.", "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.", - "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated." + "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated.", + "539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.", + "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams." } From 2898e070f6b8d18815e91a7be629bb788cb97c11 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 28 Jun 2024 17:40:15 -0400 Subject: [PATCH 2/3] Leave large strings unencoded in Node streams Also, let Node accept string chunks as long as they're following our expected constraints. This lets us test the mixed protocol using pass-throughs. --- .../src/ReactFlightDOMClientNode.js | 7 ++++++- .../src/__tests__/ReactFlightDOMNode-test.js | 19 ++++++++++++------- .../src/ReactServerStreamConfigNode.js | 3 ++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js index d0fb59c51e8b6..e7beb9586a65a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -30,6 +30,7 @@ import { createResponse, getRoot, reportGlobalError, + processStringChunk, processBinaryChunk, close, } from 'react-client/src/ReactFlightClient'; @@ -79,7 +80,11 @@ function createFromNodeStream( : undefined, ); stream.on('data', chunk => { - processBinaryChunk(response, chunk); + if (typeof chunk === 'string') { + processStringChunk(response, chunk); + } else { + processBinaryChunk(response, chunk); + } }); stream.on('error', error => { reportGlobalError(response, error); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 6f6a825e5e7de..2de34cc1c493f 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -27,6 +27,11 @@ let use; let ReactServerScheduler; let reactServerAct; +// We test pass-through without encoding strings but it should work without it too. +const streamOptions = { + objectMode: true, +}; + describe('ReactFlightDOMNode', () => { beforeEach(() => { jest.resetModules(); @@ -76,7 +81,7 @@ describe('ReactFlightDOMNode', () => { function readResult(stream) { return new Promise((resolve, reject) => { let buffer = ''; - const writable = new Stream.PassThrough(); + const writable = new Stream.PassThrough(streamOptions); writable.setEncoding('utf8'); writable.on('data', chunk => { buffer += chunk; @@ -128,7 +133,7 @@ describe('ReactFlightDOMNode', () => { const stream = await serverAct(() => ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); - const readable = new Stream.PassThrough(); + const readable = new Stream.PassThrough(streamOptions); let response; stream.pipe(readable); @@ -160,7 +165,7 @@ describe('ReactFlightDOMNode', () => { }), ); - const readable = new Stream.PassThrough(); + const readable = new Stream.PassThrough(streamOptions); const stringResult = readResult(readable); const parsedResult = ReactServerDOMClient.createFromNodeStream(readable, { @@ -206,7 +211,7 @@ describe('ReactFlightDOMNode', () => { const stream = await serverAct(() => ReactServerDOMServer.renderToPipeableStream(buffers), ); - const readable = new Stream.PassThrough(); + const readable = new Stream.PassThrough(streamOptions); const promise = ReactServerDOMClient.createFromNodeStream(readable, { moduleMap: {}, moduleLoading: webpackModuleLoading, @@ -253,7 +258,7 @@ describe('ReactFlightDOMNode', () => { const stream = await serverAct(() => ReactServerDOMServer.renderToPipeableStream(, webpackMap), ); - const readable = new Stream.PassThrough(); + const readable = new Stream.PassThrough(streamOptions); let response; stream.pipe(readable); @@ -304,7 +309,7 @@ describe('ReactFlightDOMNode', () => { ), ); - const writable = new Stream.PassThrough(); + const writable = new Stream.PassThrough(streamOptions); rscStream.pipe(writable); controller.enqueue('hi'); @@ -349,7 +354,7 @@ describe('ReactFlightDOMNode', () => { ), ); - const readable = new Stream.PassThrough(); + const readable = new Stream.PassThrough(streamOptions); rscStream.pipe(readable); const result = await ReactServerDOMClient.createFromNodeStream(readable, { diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 773c998610df0..fe03332618140 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -63,7 +63,8 @@ function writeStringChunk(destination: Destination, stringChunk: string) { currentView = new Uint8Array(VIEW_SIZE); writtenBytes = 0; } - writeToDestination(destination, textEncoder.encode(stringChunk)); + // Write the raw string chunk and let the consumer handle the encoding. + writeToDestination(destination, stringChunk); return; } From 1ceacb3f1826e419e079f019982fcd66b1ab28ec Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 28 Jun 2024 17:46:07 -0400 Subject: [PATCH 3/3] Process Flight Chunks as Strings in renderToMarkup This lets us avoid the dependency on TextDecoder/TextEncoder. --- .../src/ReactHTMLLegacyClientStreamConfig.js | 14 +++++--------- packages/react-html/src/ReactHTMLServer.js | 6 ++---- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js index 74b0503590462..eaee8d45938da 100644 --- a/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js +++ b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js @@ -7,26 +7,22 @@ * @flow */ -// TODO: The legacy one should not use binary. +export type StringDecoder = null; -export type StringDecoder = TextDecoder; - -export function createStringDecoder(): StringDecoder { - return new TextDecoder(); +export function createStringDecoder(): null { + return null; } -const decoderOptions = {stream: true}; - export function readPartialStringChunk( decoder: StringDecoder, buffer: Uint8Array, ): string { - return decoder.decode(buffer, decoderOptions); + throw new Error('Not implemented.'); } export function readFinalStringChunk( decoder: StringDecoder, buffer: Uint8Array, ): string { - return decoder.decode(buffer); + throw new Error('Not implemented.'); } diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js index 923881e4da755..d87237484c39c 100644 --- a/packages/react-html/src/ReactHTMLServer.js +++ b/packages/react-html/src/ReactHTMLServer.js @@ -22,7 +22,7 @@ import { import { createResponse as createFlightResponse, getRoot as getFlightRoot, - processBinaryChunk as processFlightBinaryChunk, + processStringChunk as processFlightStringChunk, close as closeFlight, } from 'react-client/src/ReactFlightClient'; @@ -75,12 +75,10 @@ export function renderToMarkup( options?: MarkupOptions, ): Promise { return new Promise((resolve, reject) => { - const textEncoder = new TextEncoder(); const flightDestination = { push(chunk: string | null): boolean { if (chunk !== null) { - // TODO: Legacy should not use binary streams. - processFlightBinaryChunk(flightResponse, textEncoder.encode(chunk)); + processFlightStringChunk(flightResponse, chunk); } else { closeFlight(flightResponse); }