Skip to content

Commit d9c3331

Browse files
authored
[Flight] Add Serialization of Typed Arrays / ArrayBuffer / DataView (facebook#26954)
This uses the same mechanism as [large strings](facebook#26932) to encode chunks of length based binary data in the RSC payload behind a flag. I introduce a new BinaryChunk type that's specific to each stream and ways to convert into it. That's because we sometimes need all chunks to be Uint8Array for the output, even if the source is another array buffer view, and sometimes we need to clone it before transferring. Each type of typed array is its own row tag. This lets us ensure that the instance is directly in the right format in the cached entry instead of creating a wrapper at each reference. Ideally this is also how Map/Set should work but those are lazy which complicates that approach a bit. We assume both server and client use little-endian for now. If we want to support other modes, we'd convert it to/from little-endian so that the transfer protocol is always little-endian. That way the common clients can be the fastest possible. So far this only implements Server to Client. Still need to implement Client to Server for parity. NOTE: This is the first time we make RSC effectively a binary format. This is not compatible with existing SSR techniques which serialize the stream as unicode in the HTML. To be compatible, those implementations would have to use base64 or something like that. Which is what we'll do when we move this technique to be built-in to Fizz.
1 parent 2153a29 commit d9c3331

25 files changed

+517
-66
lines changed

.eslintrc.js

+3
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,14 @@ module.exports = {
454454
$PropertyType: 'readonly',
455455
$ReadOnly: 'readonly',
456456
$ReadOnlyArray: 'readonly',
457+
$ArrayBufferView: 'readonly',
457458
$Shape: 'readonly',
458459
AnimationFrameID: 'readonly',
459460
// For Flow type annotation. Only `BigInt` is valid at runtime.
460461
bigint: 'readonly',
461462
BigInt: 'readonly',
463+
BigInt64Array: 'readonly',
464+
BigUint64Array: 'readonly',
462465
Class: 'readonly',
463466
ClientRect: 'readonly',
464467
CopyInspectedElementPath: 'readonly',

packages/react-client/src/ReactFlightClient.js

+143-11
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
2121

2222
import type {CallServerCallback} from './ReactFlightReplyClient';
2323

24+
import {enableBinaryFlight} from 'shared/ReactFeatureFlags';
25+
2426
import {
2527
resolveClientReference,
2628
preloadModule,
@@ -297,6 +299,14 @@ function createInitializedTextChunk(
297299
return new Chunk(INITIALIZED, value, null, response);
298300
}
299301

302+
function createInitializedBufferChunk(
303+
response: Response,
304+
value: $ArrayBufferView | ArrayBuffer,
305+
): InitializedChunk<Uint8Array> {
306+
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
307+
return new Chunk(INITIALIZED, value, null, response);
308+
}
309+
300310
function resolveModelChunk<T>(
301311
chunk: SomeChunk<T>,
302312
value: UninitializedModel,
@@ -738,6 +748,16 @@ function resolveText(response: Response, id: number, text: string): void {
738748
chunks.set(id, createInitializedTextChunk(response, text));
739749
}
740750

751+
function resolveBuffer(
752+
response: Response,
753+
id: number,
754+
buffer: $ArrayBufferView | ArrayBuffer,
755+
): void {
756+
const chunks = response._chunks;
757+
// We assume that we always reference buffers after they've been emitted.
758+
chunks.set(id, createInitializedBufferChunk(response, buffer));
759+
}
760+
741761
function resolveModule(
742762
response: Response,
743763
id: number,
@@ -856,24 +876,120 @@ function resolveHint(
856876
dispatchHint(code, hintModel);
857877
}
858878

879+
function mergeBuffer(
880+
buffer: Array<Uint8Array>,
881+
lastChunk: Uint8Array,
882+
): Uint8Array {
883+
const l = buffer.length;
884+
// Count the bytes we'll need
885+
let byteLength = lastChunk.length;
886+
for (let i = 0; i < l; i++) {
887+
byteLength += buffer[i].byteLength;
888+
}
889+
// Allocate enough contiguous space
890+
const result = new Uint8Array(byteLength);
891+
let offset = 0;
892+
// Copy all the buffers into it.
893+
for (let i = 0; i < l; i++) {
894+
const chunk = buffer[i];
895+
result.set(chunk, offset);
896+
offset += chunk.byteLength;
897+
}
898+
result.set(lastChunk, offset);
899+
return result;
900+
}
901+
902+
function resolveTypedArray(
903+
response: Response,
904+
id: number,
905+
buffer: Array<Uint8Array>,
906+
lastChunk: Uint8Array,
907+
constructor: any,
908+
bytesPerElement: number,
909+
): void {
910+
// If the view fits into one original buffer, we just reuse that buffer instead of
911+
// copying it out to a separate copy. This means that it's not always possible to
912+
// transfer these values to other threads without copying first since they may
913+
// share array buffer. For this to work, it must also have bytes aligned to a
914+
// multiple of a size of the type.
915+
const chunk =
916+
buffer.length === 0 && lastChunk.byteOffset % bytesPerElement === 0
917+
? lastChunk
918+
: mergeBuffer(buffer, lastChunk);
919+
// TODO: The transfer protocol of RSC is little-endian. If the client isn't little-endian
920+
// we should convert it instead. In practice big endian isn't really Web compatible so it's
921+
// somewhat safe to assume that browsers aren't going to run it, but maybe there's some SSR
922+
// server that's affected.
923+
const view: $ArrayBufferView = new constructor(
924+
chunk.buffer,
925+
chunk.byteOffset,
926+
chunk.byteLength / bytesPerElement,
927+
);
928+
resolveBuffer(response, id, view);
929+
}
930+
859931
function processFullRow(
860932
response: Response,
861933
id: number,
862934
tag: number,
863935
buffer: Array<Uint8Array>,
864-
lastChunk: string | Uint8Array,
936+
chunk: Uint8Array,
865937
): void {
866-
let row = '';
938+
if (enableBinaryFlight) {
939+
switch (tag) {
940+
case 65 /* "A" */:
941+
// We must always clone to extract it into a separate buffer instead of just a view.
942+
resolveBuffer(response, id, mergeBuffer(buffer, chunk).buffer);
943+
return;
944+
case 67 /* "C" */:
945+
resolveTypedArray(response, id, buffer, chunk, Int8Array, 1);
946+
return;
947+
case 99 /* "c" */:
948+
resolveBuffer(
949+
response,
950+
id,
951+
buffer.length === 0 ? chunk : mergeBuffer(buffer, chunk),
952+
);
953+
return;
954+
case 85 /* "U" */:
955+
resolveTypedArray(response, id, buffer, chunk, Uint8ClampedArray, 1);
956+
return;
957+
case 83 /* "S" */:
958+
resolveTypedArray(response, id, buffer, chunk, Int16Array, 2);
959+
return;
960+
case 115 /* "s" */:
961+
resolveTypedArray(response, id, buffer, chunk, Uint16Array, 2);
962+
return;
963+
case 76 /* "L" */:
964+
resolveTypedArray(response, id, buffer, chunk, Int32Array, 4);
965+
return;
966+
case 108 /* "l" */:
967+
resolveTypedArray(response, id, buffer, chunk, Uint32Array, 4);
968+
return;
969+
case 70 /* "F" */:
970+
resolveTypedArray(response, id, buffer, chunk, Float32Array, 4);
971+
return;
972+
case 68 /* "D" */:
973+
resolveTypedArray(response, id, buffer, chunk, Float64Array, 8);
974+
return;
975+
case 78 /* "N" */:
976+
resolveTypedArray(response, id, buffer, chunk, BigInt64Array, 8);
977+
return;
978+
case 109 /* "m" */:
979+
resolveTypedArray(response, id, buffer, chunk, BigUint64Array, 8);
980+
return;
981+
case 86 /* "V" */:
982+
resolveTypedArray(response, id, buffer, chunk, DataView, 1);
983+
return;
984+
}
985+
}
986+
867987
const stringDecoder = response._stringDecoder;
988+
let row = '';
868989
for (let i = 0; i < buffer.length; i++) {
869-
const chunk = buffer[i];
870-
row += readPartialStringChunk(stringDecoder, chunk);
871-
}
872-
if (typeof lastChunk === 'string') {
873-
row += lastChunk;
874-
} else {
875-
row += readFinalStringChunk(stringDecoder, lastChunk);
990+
row += readPartialStringChunk(stringDecoder, buffer[i]);
876991
}
992+
row += readFinalStringChunk(stringDecoder, chunk);
877993
switch (tag) {
878994
case 73 /* "I" */: {
879995
resolveModule(response, id, row);
@@ -903,7 +1019,7 @@ function processFullRow(
9031019
resolveText(response, id, row);
9041020
return;
9051021
}
906-
default: {
1022+
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
9071023
// We assume anything else is JSON.
9081024
resolveModel(response, id, row);
9091025
return;
@@ -937,7 +1053,23 @@ export function processBinaryChunk(
9371053
}
9381054
case ROW_TAG: {
9391055
const resolvedRowTag = chunk[i];
940-
if (resolvedRowTag === 84 /* "T" */) {
1056+
if (
1057+
resolvedRowTag === 84 /* "T" */ ||
1058+
(enableBinaryFlight &&
1059+
(resolvedRowTag === 65 /* "A" */ ||
1060+
resolvedRowTag === 67 /* "C" */ ||
1061+
resolvedRowTag === 99 /* "c" */ ||
1062+
resolvedRowTag === 85 /* "U" */ ||
1063+
resolvedRowTag === 83 /* "S" */ ||
1064+
resolvedRowTag === 115 /* "s" */ ||
1065+
resolvedRowTag === 76 /* "L" */ ||
1066+
resolvedRowTag === 108 /* "l" */ ||
1067+
resolvedRowTag === 70 /* "F" */ ||
1068+
resolvedRowTag === 68 /* "D" */ ||
1069+
resolvedRowTag === 78 /* "N" */ ||
1070+
resolvedRowTag === 109 /* "m" */ ||
1071+
resolvedRowTag === 86)) /* "V" */
1072+
) {
9411073
rowTag = resolvedRowTag;
9421074
rowState = ROW_LENGTH;
9431075
i++;

packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js

+13-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface Destination {
1414

1515
export opaque type PrecomputedChunk = string;
1616
export opaque type Chunk = string;
17+
export opaque type BinaryChunk = string;
1718

1819
export function scheduleWork(callback: () => void) {
1920
callback();
@@ -25,14 +26,14 @@ export function beginWriting(destination: Destination) {}
2526

2627
export function writeChunk(
2728
destination: Destination,
28-
chunk: Chunk | PrecomputedChunk,
29+
chunk: Chunk | PrecomputedChunk | BinaryChunk,
2930
): void {
3031
writeChunkAndReturn(destination, chunk);
3132
}
3233

3334
export function writeChunkAndReturn(
3435
destination: Destination,
35-
chunk: Chunk | PrecomputedChunk,
36+
chunk: Chunk | PrecomputedChunk | BinaryChunk,
3637
): boolean {
3738
return destination.push(chunk);
3839
}
@@ -51,6 +52,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
5152
return content;
5253
}
5354

55+
export function typedArrayToBinaryChunk(
56+
content: $ArrayBufferView,
57+
): BinaryChunk {
58+
throw new Error('Not implemented.');
59+
}
60+
5461
export function clonePrecomputedChunk(
5562
chunk: PrecomputedChunk,
5663
): PrecomputedChunk {
@@ -61,6 +68,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
6168
throw new Error('Not implemented.');
6269
}
6370

71+
export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number {
72+
throw new Error('Not implemented.');
73+
}
74+
6475
export function closeWithError(destination: Destination, error: mixed): void {
6576
// $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
6677
destination.destroy(error);

packages/react-server-dom-fb/src/ReactServerStreamConfigFB.js

+13-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type Destination = {
1616

1717
export opaque type PrecomputedChunk = string;
1818
export opaque type Chunk = string;
19+
export opaque type BinaryChunk = string;
1920

2021
export function scheduleWork(callback: () => void) {
2122
// We don't schedule work in this model, and instead expect performWork to always be called repeatedly.
@@ -30,14 +31,14 @@ export function beginWriting(destination: Destination) {}
3031

3132
export function writeChunk(
3233
destination: Destination,
33-
chunk: Chunk | PrecomputedChunk,
34+
chunk: Chunk | PrecomputedChunk | BinaryChunk,
3435
): void {
3536
destination.buffer += chunk;
3637
}
3738

3839
export function writeChunkAndReturn(
3940
destination: Destination,
40-
chunk: Chunk | PrecomputedChunk,
41+
chunk: Chunk | PrecomputedChunk | BinaryChunk,
4142
): boolean {
4243
destination.buffer += chunk;
4344
return true;
@@ -57,6 +58,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
5758
return content;
5859
}
5960

61+
export function typedArrayToBinaryChunk(
62+
content: $ArrayBufferView,
63+
): BinaryChunk {
64+
throw new Error('Not implemented.');
65+
}
66+
6067
export function clonePrecomputedChunk(
6168
chunk: PrecomputedChunk,
6269
): PrecomputedChunk {
@@ -67,6 +74,10 @@ export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
6774
throw new Error('Not implemented.');
6875
}
6976

77+
export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number {
78+
throw new Error('Not implemented.');
79+
}
80+
7081
export function closeWithError(destination: Destination, error: mixed): void {
7182
destination.done = true;
7283
destination.fatal = true;

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

+27
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,31 @@ describe('ReactFlightDOMEdge', () => {
153153
expect(result.text).toBe(testString);
154154
expect(result.text2).toBe(testString2);
155155
});
156+
157+
// @gate enableBinaryFlight
158+
it('should be able to serialize any kind of typed array', async () => {
159+
const buffer = new Uint8Array([
160+
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
161+
]).buffer;
162+
const buffers = [
163+
buffer,
164+
new Int8Array(buffer, 1),
165+
new Uint8Array(buffer, 2),
166+
new Uint8ClampedArray(buffer, 2),
167+
new Int16Array(buffer, 2),
168+
new Uint16Array(buffer, 2),
169+
new Int32Array(buffer, 4),
170+
new Uint32Array(buffer, 4),
171+
new Float32Array(buffer, 4),
172+
new Float64Array(buffer, 0),
173+
new BigInt64Array(buffer, 0),
174+
new BigUint64Array(buffer, 0),
175+
new DataView(buffer, 3),
176+
];
177+
const stream = passThrough(
178+
ReactServerDOMServer.renderToReadableStream(buffers),
179+
);
180+
const result = await ReactServerDOMClient.createFromReadableStream(stream);
181+
expect(result).toEqual(buffers);
182+
});
156183
});

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js

+28
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,32 @@ describe('ReactFlightDOMNode', () => {
131131
// Should still match the result when parsed
132132
expect(result.text).toBe(testString);
133133
});
134+
135+
// @gate enableBinaryFlight
136+
it('should be able to serialize any kind of typed array', async () => {
137+
const buffer = new Uint8Array([
138+
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
139+
]).buffer;
140+
const buffers = [
141+
buffer,
142+
new Int8Array(buffer, 1),
143+
new Uint8Array(buffer, 2),
144+
new Uint8ClampedArray(buffer, 2),
145+
new Int16Array(buffer, 2),
146+
new Uint16Array(buffer, 2),
147+
new Int32Array(buffer, 4),
148+
new Uint32Array(buffer, 4),
149+
new Float32Array(buffer, 4),
150+
new Float64Array(buffer, 0),
151+
new BigInt64Array(buffer, 0),
152+
new BigUint64Array(buffer, 0),
153+
new DataView(buffer, 3),
154+
];
155+
const stream = ReactServerDOMServer.renderToPipeableStream(buffers);
156+
const readable = new Stream.PassThrough();
157+
const promise = ReactServerDOMClient.createFromNodeStream(readable);
158+
stream.pipe(readable);
159+
const result = await promise;
160+
expect(result).toEqual(buffers);
161+
});
134162
});

0 commit comments

Comments
 (0)