mirror of
https://github.com/facebook/react.git
synced 2026-02-24 12:43:00 +00:00
It can be efficient to accept raw string chunks to pass through a stream instead of encoding them into a binary copy first. Previously our Flight parsers didn't accept receiving string chunks. That's partly because we sometimes need to encode binary chunks anyway so string only transport isn't enough but some chunks can be strings. This adds a partial ability for chunks to be received as strings. However, accepting strings comes with some downsides. E.g. if the strings are split up we need to buffer it which compromises the perf for the common case. If the chunk represents binary data, then we'd need to encode it back into a typed array which would require a TextEncoder dependency in the parser. If the string chunk represents a byte length encoded string we don't know how many unicode characters to read without measuring them in terms of binary - also requiring a TextEncoder. This PR is mainly intended for use for pass-through within the same memory. We can simplify the implementation by assuming that any string chunk is passed as the original chunk. This requires that the server stream config doesn't arbitrarily concatenate strings (e.g. large strings should not be concatenated which is probably a good heuristic anyway). It also means that this is not suitable to be used with for example receiving string chunks on the client by passing them through SSR hydration data - except if the encoding that way was only used with chunks that were already encoded as strings by Flight. Web streams mostly just work on binary data anyway so they can't use this. In Node.js streams we concatenate precomputed and small strings into larger buffers. It might make sense to do that using string ropes instead. However, in the meantime we can at least pass large strings that are outside our buffer view size as raw strings. There's no benefit to us eagerly encoding those. 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. This can also be useful when the RSC server is in the same environment as the SSR server as they don't have to go from strings to typed arrays back to strings. Now we can also use this in the pass-through used in renderToMarkup. This lets us avoid the dependency on TextDecoder/TextEncoder in that package.
239 lines
6.7 KiB
JavaScript
239 lines
6.7 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import type {Writable} from 'stream';
|
|
|
|
import {TextEncoder} from 'util';
|
|
import {createHash} from 'crypto';
|
|
|
|
interface MightBeFlushable {
|
|
flush?: () => void;
|
|
}
|
|
|
|
export type Destination = Writable & MightBeFlushable;
|
|
|
|
export type PrecomputedChunk = Uint8Array;
|
|
export opaque type Chunk = string;
|
|
export type BinaryChunk = Uint8Array;
|
|
|
|
export function scheduleWork(callback: () => void) {
|
|
setImmediate(callback);
|
|
}
|
|
|
|
export const scheduleMicrotask = queueMicrotask;
|
|
|
|
export function flushBuffered(destination: Destination) {
|
|
// If we don't have any more data to send right now.
|
|
// Flush whatever is in the buffer to the wire.
|
|
if (typeof destination.flush === 'function') {
|
|
// By convention the Zlib streams provide a flush function for this purpose.
|
|
// For Express, compression middleware adds this method.
|
|
destination.flush();
|
|
}
|
|
}
|
|
|
|
const VIEW_SIZE = 2048;
|
|
let currentView = null;
|
|
let writtenBytes = 0;
|
|
let destinationHasCapacity = true;
|
|
|
|
export function beginWriting(destination: Destination) {
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = 0;
|
|
destinationHasCapacity = true;
|
|
}
|
|
|
|
function writeStringChunk(destination: Destination, stringChunk: string) {
|
|
if (stringChunk.length === 0) {
|
|
return;
|
|
}
|
|
// maximum possible view needed to encode entire string
|
|
if (stringChunk.length * 3 > VIEW_SIZE) {
|
|
if (writtenBytes > 0) {
|
|
writeToDestination(
|
|
destination,
|
|
((currentView: any): Uint8Array).subarray(0, writtenBytes),
|
|
);
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = 0;
|
|
}
|
|
// Write the raw string chunk and let the consumer handle the encoding.
|
|
writeToDestination(destination, stringChunk);
|
|
return;
|
|
}
|
|
|
|
let target: Uint8Array = (currentView: any);
|
|
if (writtenBytes > 0) {
|
|
target = ((currentView: any): Uint8Array).subarray(writtenBytes);
|
|
}
|
|
const {read, written} = textEncoder.encodeInto(stringChunk, target);
|
|
writtenBytes += written;
|
|
|
|
if (read < stringChunk.length) {
|
|
writeToDestination(
|
|
destination,
|
|
(currentView: any).subarray(0, writtenBytes),
|
|
);
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = textEncoder.encodeInto(
|
|
stringChunk.slice(read),
|
|
(currentView: any),
|
|
).written;
|
|
}
|
|
|
|
if (writtenBytes === VIEW_SIZE) {
|
|
writeToDestination(destination, (currentView: any));
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = 0;
|
|
}
|
|
}
|
|
|
|
function writeViewChunk(
|
|
destination: Destination,
|
|
chunk: PrecomputedChunk | BinaryChunk,
|
|
) {
|
|
if (chunk.byteLength === 0) {
|
|
return;
|
|
}
|
|
if (chunk.byteLength > VIEW_SIZE) {
|
|
// this chunk may overflow a single view which implies it was not
|
|
// one that is cached by the streaming renderer. We will enqueu
|
|
// it directly and expect it is not re-used
|
|
if (writtenBytes > 0) {
|
|
writeToDestination(
|
|
destination,
|
|
((currentView: any): Uint8Array).subarray(0, writtenBytes),
|
|
);
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = 0;
|
|
}
|
|
writeToDestination(destination, chunk);
|
|
return;
|
|
}
|
|
|
|
let bytesToWrite = chunk;
|
|
const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes;
|
|
if (allowableBytes < bytesToWrite.byteLength) {
|
|
// this chunk would overflow the current view. We enqueue a full view
|
|
// and start a new view with the remaining chunk
|
|
if (allowableBytes === 0) {
|
|
// the current view is already full, send it
|
|
writeToDestination(destination, (currentView: any));
|
|
} else {
|
|
// fill up the current view and apply the remaining chunk bytes
|
|
// to a new view.
|
|
((currentView: any): Uint8Array).set(
|
|
bytesToWrite.subarray(0, allowableBytes),
|
|
writtenBytes,
|
|
);
|
|
writtenBytes += allowableBytes;
|
|
writeToDestination(destination, (currentView: any));
|
|
bytesToWrite = bytesToWrite.subarray(allowableBytes);
|
|
}
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = 0;
|
|
}
|
|
((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes);
|
|
writtenBytes += bytesToWrite.byteLength;
|
|
|
|
if (writtenBytes === VIEW_SIZE) {
|
|
writeToDestination(destination, (currentView: any));
|
|
currentView = new Uint8Array(VIEW_SIZE);
|
|
writtenBytes = 0;
|
|
}
|
|
}
|
|
|
|
export function writeChunk(
|
|
destination: Destination,
|
|
chunk: PrecomputedChunk | Chunk | BinaryChunk,
|
|
): void {
|
|
if (typeof chunk === 'string') {
|
|
writeStringChunk(destination, chunk);
|
|
} else {
|
|
writeViewChunk(destination, ((chunk: any): PrecomputedChunk | BinaryChunk));
|
|
}
|
|
}
|
|
|
|
function writeToDestination(
|
|
destination: Destination,
|
|
view: string | Uint8Array,
|
|
) {
|
|
const currentHasCapacity = destination.write(view);
|
|
destinationHasCapacity = destinationHasCapacity && currentHasCapacity;
|
|
}
|
|
|
|
export function writeChunkAndReturn(
|
|
destination: Destination,
|
|
chunk: PrecomputedChunk | Chunk,
|
|
): boolean {
|
|
writeChunk(destination, chunk);
|
|
return destinationHasCapacity;
|
|
}
|
|
|
|
export function completeWriting(destination: Destination) {
|
|
if (currentView && writtenBytes > 0) {
|
|
destination.write(currentView.subarray(0, writtenBytes));
|
|
}
|
|
currentView = null;
|
|
writtenBytes = 0;
|
|
destinationHasCapacity = true;
|
|
}
|
|
|
|
export function close(destination: Destination) {
|
|
destination.end();
|
|
}
|
|
|
|
const textEncoder = new TextEncoder();
|
|
|
|
export function stringToChunk(content: string): Chunk {
|
|
return content;
|
|
}
|
|
|
|
export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
|
|
const precomputedChunk = textEncoder.encode(content);
|
|
|
|
if (__DEV__) {
|
|
if (precomputedChunk.byteLength > VIEW_SIZE) {
|
|
console.error(
|
|
'precomputed chunks must be smaller than the view size configured for this host. This is a bug in React.',
|
|
);
|
|
}
|
|
}
|
|
|
|
return precomputedChunk;
|
|
}
|
|
|
|
export function typedArrayToBinaryChunk(
|
|
content: $ArrayBufferView,
|
|
): BinaryChunk {
|
|
// Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays.
|
|
return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
|
|
}
|
|
|
|
export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
|
|
return typeof chunk === 'string'
|
|
? Buffer.byteLength(chunk, 'utf8')
|
|
: chunk.byteLength;
|
|
}
|
|
|
|
export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number {
|
|
return chunk.byteLength;
|
|
}
|
|
|
|
export function closeWithError(destination: Destination, error: mixed): void {
|
|
// $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
|
|
destination.destroy(error);
|
|
}
|
|
|
|
export function createFastHash(input: string): string | number {
|
|
const hash = createHash('md5');
|
|
hash.update(input);
|
|
return hash.digest('hex');
|
|
}
|