From 99d73718636d7930a2abf0030c51c1d7710b13d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 10 Mar 2020 14:55:04 -0700 Subject: [PATCH] [Flight] Split Streaming from Relay Implemenation (#18260) * Add ReactFlightServerConfig intermediate This just forwards to the stream version of Flight which is itself forked between Node and W3C streams. The dom-relay goes directly to the Relay config though which allows it to avoid the stream part of Flight. * Separate streaming protocol into the Stream config * Split streaming parts into the ReactFlightServerConfigStream This decouples it so that the Relay implementation doesn't have to encode the JSON to strings. Instead it can be fed the values as JSON objects and do its own encoding. * Split FlightClient into a basic part and a stream part Same split as the server. * Expose lower level async hooks to Relay This requires an external helper file that we'll wire up internally. --- packages/react-client/flight.js | 2 +- .../react-client/src/ReactFlightClient.js | 141 ++++-------------- .../src/ReactFlightClientHostConfigBrowser.js | 2 - .../src/ReactFlightClientStream.js | 116 ++++++++++++++ .../src/ReactFlightDOMRelayClient.js | 48 ++++-- .../ReactFlightDOMRelayClientHostConfig.js | 2 - .../src/ReactFlightDOMRelayServer.js | 9 +- .../ReactFlightDOMRelayServerHostConfig.js | 100 +++++++++++-- .../ReactFlightDOMRelayServerIntegration.js | 28 ++++ .../ReactFlightDOMRelay-test.internal.js | 32 +++- .../src/ReactFlightDOMClient.js | 16 +- .../src/ReactNoopFlightClient.js | 4 +- .../react-server/src/ReactFlightServer.js | 131 ++++------------ .../src/ReactFlightServerConfig.js | 22 +++ .../src/ReactFlightServerConfigStream.js | 114 ++++++++++++++ .../forks/ReactFlightServerConfig.custom.js | 10 ++ .../ReactFlightServerConfig.dom-browser.js | 10 ++ .../ReactFlightServerConfig.dom-relay.js | 10 ++ .../src/forks/ReactFlightServerConfig.dom.js | 10 ++ .../ReactServerStreamConfig.dom-relay.js | 2 +- scripts/flow/config/flowconfig | 1 + scripts/flow/createFlowConfigs.js | 1 + scripts/flow/react-relay-hooks.js | 32 ++++ scripts/jest/setupHostConfigs.js | 7 + scripts/rollup/bundles.js | 6 +- scripts/rollup/forks.js | 28 ++++ 26 files changed, 615 insertions(+), 269 deletions(-) create mode 100644 packages/react-client/src/ReactFlightClientStream.js create mode 100644 packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js create mode 100644 packages/react-server/src/ReactFlightServerConfig.js create mode 100644 packages/react-server/src/ReactFlightServerConfigStream.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.custom.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.dom.js create mode 100644 scripts/flow/react-relay-hooks.js diff --git a/packages/react-client/flight.js b/packages/react-client/flight.js index 7d0a0b03ba920..2b9b3f45d67bb 100644 --- a/packages/react-client/flight.js +++ b/packages/react-client/flight.js @@ -7,4 +7,4 @@ * @flow */ -export * from './src/ReactFlightClient'; +export * from './src/ReactFlightClientStream'; diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 3adab738369df..b158c0039d1ba 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -7,25 +7,17 @@ * @flow */ -import type {Source, StringDecoder} from './ReactFlightClientHostConfig'; - -import { - supportsBinaryStreams, - createStringDecoder, - readPartialStringChunk, - readFinalStringChunk, -} from './ReactFlightClientHostConfig'; - export type ReactModelRoot = {| model: T, |}; -type JSONValue = +export type JSONValue = | number | null | boolean | string - | {[key: string]: JSONValue, ...}; + | {[key: string]: JSONValue} + | Array; const PENDING = 0; const RESOLVED = 1; @@ -48,39 +40,23 @@ type ErroredChunk = {| |}; type Chunk = PendingChunk | ResolvedChunk | ErroredChunk; -type OpaqueResponseWithoutDecoder = { - source: Source, +export type Response = { partialRow: string, modelRoot: ReactModelRoot, chunks: Map, - fromJSON: (key: string, value: JSONValue) => any, - ... }; -type OpaqueResponse = OpaqueResponseWithoutDecoder & { - stringDecoder: StringDecoder, - ... -}; - -export function createResponse(source: Source): OpaqueResponse { +export function createResponse(): Response { let modelRoot: ReactModelRoot = ({}: any); let rootChunk: Chunk = createPendingChunk(); definePendingProperty(modelRoot, 'model', rootChunk); let chunks: Map = new Map(); chunks.set(0, rootChunk); - - let response: OpaqueResponse = (({ - source, + let response = { partialRow: '', modelRoot, chunks: chunks, - fromJSON: function(key, value) { - return parseFromJSON(response, this, key, value); - }, - }: OpaqueResponseWithoutDecoder): any); - if (supportsBinaryStreams) { - response.stringDecoder = createStringDecoder(); - } + }; return response; } @@ -138,10 +114,7 @@ function resolveChunk(chunk: Chunk, value: mixed): void { // Report that any missing chunks in the model is now going to throw this // error upon read. Also notify any pending promises. -export function reportGlobalError( - response: OpaqueResponse, - error: Error, -): void { +export function reportGlobalError(response: Response, error: Error): void { response.chunks.forEach(chunk => { // If this chunk was already resolved or errored, it won't // trigger an error but if it wasn't then we need to @@ -168,8 +141,8 @@ function definePendingProperty( }); } -function parseFromJSON( - response: OpaqueResponse, +export function parseModelFromJSON( + response: Response, targetObj: Object, key: string, value: JSONValue, @@ -195,12 +168,11 @@ function parseFromJSON( return value; } -function resolveJSONRow( - response: OpaqueResponse, +export function resolveModelChunk( + response: Response, id: number, - json: string, + model: T, ): void { - let model = JSON.parse(json, response.fromJSON); let chunks = response.chunks; let chunk = chunks.get(id); if (!chunk) { @@ -210,81 +182,24 @@ function resolveJSONRow( } } -function processFullRow(response: OpaqueResponse, row: string): void { - if (row === '') { - return; - } - let tag = row[0]; - switch (tag) { - case 'J': { - let colon = row.indexOf(':', 1); - let id = parseInt(row.substring(1, colon), 16); - let json = row.substring(colon + 1); - resolveJSONRow(response, id, json); - return; - } - case 'E': { - let colon = row.indexOf(':', 1); - let id = parseInt(row.substring(1, colon), 16); - let json = row.substring(colon + 1); - let errorInfo = JSON.parse(json); - let error = new Error(errorInfo.message); - error.stack = errorInfo.stack; - let chunks = response.chunks; - let chunk = chunks.get(id); - if (!chunk) { - chunks.set(id, createErrorChunk(error)); - } else { - triggerErrorOnChunk(chunk, error); - } - return; - } - default: { - // Assume this is the root model. - resolveJSONRow(response, 0, row); - return; - } - } -} - -export function processStringChunk( - response: OpaqueResponse, - chunk: string, - offset: number, -): void { - let linebreak = chunk.indexOf('\n', offset); - while (linebreak > -1) { - let fullrow = response.partialRow + chunk.substring(offset, linebreak); - processFullRow(response, fullrow); - response.partialRow = ''; - offset = linebreak + 1; - linebreak = chunk.indexOf('\n', offset); - } - response.partialRow += chunk.substring(offset); -} - -export function processBinaryChunk( - response: OpaqueResponse, - chunk: Uint8Array, +export function resolveErrorChunk( + response: Response, + id: number, + message: string, + stack: string, ): void { - if (!supportsBinaryStreams) { - throw new Error("This environment don't support binary chunks."); - } - let stringDecoder = response.stringDecoder; - let linebreak = chunk.indexOf(10); // newline - while (linebreak > -1) { - let fullrow = - response.partialRow + - readFinalStringChunk(stringDecoder, chunk.subarray(0, linebreak)); - processFullRow(response, fullrow); - response.partialRow = ''; - chunk = chunk.subarray(linebreak + 1); - linebreak = chunk.indexOf(10); // newline + let error = new Error(message); + error.stack = stack; + let chunks = response.chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, createErrorChunk(error)); + } else { + triggerErrorOnChunk(chunk, error); } - response.partialRow += readPartialStringChunk(stringDecoder, chunk); } -export function complete(response: OpaqueResponse): void { +export function close(response: Response): void { // In case there are any remaining unresolved chunks, they won't // be resolved now. So we need to issue an error to those. // Ideally we should be able to early bail out if we kept a @@ -292,6 +207,6 @@ export function complete(response: OpaqueResponse): void { reportGlobalError(response, new Error('Connection closed.')); } -export function getModelRoot(response: OpaqueResponse): ReactModelRoot { +export function getModelRoot(response: Response): ReactModelRoot { return response.modelRoot; } diff --git a/packages/react-client/src/ReactFlightClientHostConfigBrowser.js b/packages/react-client/src/ReactFlightClientHostConfigBrowser.js index a3ba45faee0e3..d5aef79df514d 100644 --- a/packages/react-client/src/ReactFlightClientHostConfigBrowser.js +++ b/packages/react-client/src/ReactFlightClientHostConfigBrowser.js @@ -7,8 +7,6 @@ * @flow */ -export type Source = Promise | ReadableStream | XMLHttpRequest; - export type StringDecoder = TextDecoder; export const supportsBinaryStreams = true; diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js new file mode 100644 index 0000000000000..27e5eabaa8f8f --- /dev/null +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -0,0 +1,116 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {Response as ResponseBase, JSONValue} from './ReactFlightClient'; + +import type {StringDecoder} from './ReactFlightClientHostConfig'; + +import { + createResponse as createResponseImpl, + resolveModelChunk, + resolveErrorChunk, + parseModelFromJSON, +} from './ReactFlightClient'; + +import { + supportsBinaryStreams, + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from './ReactFlightClientHostConfig'; + +export type ReactModelRoot = {| + model: T, +|}; + +type Response = ResponseBase & { + fromJSON: (key: string, value: JSONValue) => any, + stringDecoder: StringDecoder, +}; + +export function createResponse(): Response { + let response: Response = (createResponseImpl(): any); + response.fromJSON = function(key: string, value: JSONValue) { + return parseModelFromJSON(response, this, key, value); + }; + if (supportsBinaryStreams) { + response.stringDecoder = createStringDecoder(); + } + return response; +} + +function processFullRow(response: Response, row: string): void { + if (row === '') { + return; + } + let tag = row[0]; + switch (tag) { + case 'J': { + let colon = row.indexOf(':', 1); + let id = parseInt(row.substring(1, colon), 16); + let json = row.substring(colon + 1); + let model = JSON.parse(json, response.fromJSON); + resolveModelChunk(response, id, model); + return; + } + case 'E': { + let colon = row.indexOf(':', 1); + let id = parseInt(row.substring(1, colon), 16); + let json = row.substring(colon + 1); + let errorInfo = JSON.parse(json); + resolveErrorChunk(response, id, errorInfo.message, errorInfo.stack); + return; + } + default: { + // Assume this is the root model. + let model = JSON.parse(row, response.fromJSON); + resolveModelChunk(response, 0, model); + return; + } + } +} + +export function processStringChunk( + response: Response, + chunk: string, + offset: number, +): void { + let linebreak = chunk.indexOf('\n', offset); + while (linebreak > -1) { + let fullrow = response.partialRow + chunk.substring(offset, linebreak); + processFullRow(response, fullrow); + response.partialRow = ''; + offset = linebreak + 1; + linebreak = chunk.indexOf('\n', offset); + } + response.partialRow += chunk.substring(offset); +} + +export function processBinaryChunk( + response: Response, + chunk: Uint8Array, +): void { + if (!supportsBinaryStreams) { + throw new Error("This environment don't support binary chunks."); + } + let stringDecoder = response.stringDecoder; + let linebreak = chunk.indexOf(10); // newline + while (linebreak > -1) { + let fullrow = + response.partialRow + + readFinalStringChunk(stringDecoder, chunk.subarray(0, linebreak)); + processFullRow(response, fullrow); + response.partialRow = ''; + chunk = chunk.subarray(linebreak + 1); + linebreak = chunk.indexOf(10); // newline + } + response.partialRow += readPartialStringChunk(stringDecoder, chunk); +} + +export {reportGlobalError, close, getModelRoot} from './ReactFlightClient'; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js index 9507e654d607d..2a9f7623fe8c9 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClient.js @@ -7,24 +7,48 @@ * @flow */ -import type {ReactModelRoot} from 'react-client/src/ReactFlightClient'; +import type {Response, JSONValue} from 'react-client/src/ReactFlightClient'; import { createResponse, getModelRoot, - processStringChunk, - complete, + parseModelFromJSON, + resolveModelChunk, + resolveErrorChunk, + close, } from 'react-client/src/ReactFlightClient'; -type EncodedData = Array; - -function read(data: EncodedData): ReactModelRoot { - let response = createResponse(data); - for (let i = 0; i < data.length; i++) { - processStringChunk(response, data[i], 0); +function parseModel(response, targetObj, key, value) { + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + value[i] = parseModel(response, value, '' + i, value[i]); + } + } else { + for (let innerKey in value) { + value[innerKey] = parseModel( + response, + value, + innerKey, + value[innerKey], + ); + } + } } - complete(response); - return getModelRoot(response); + return parseModelFromJSON(response, targetObj, key, value); } -export {read}; +export {createResponse, getModelRoot, close}; + +export function resolveModel(response: Response, id: number, json: JSONValue) { + resolveModelChunk(response, id, parseModel(response, {}, '', json)); +} + +export function resolveError( + response: Response, + id: number, + message: string, + stack: string, +) { + resolveErrorChunk(response, id, message, stack); +} diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index 22aa7844c44ad..17f29a9f26c50 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -7,8 +7,6 @@ * @flow */ -export type Source = Array; - export type StringDecoder = void; export const supportsBinaryStreams = false; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js index 8945e0e1d9526..9c589a9f61daa 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServer.js @@ -8,16 +8,13 @@ */ import type {ReactModel} from 'react-server/src/ReactFlightServer'; +import type {Destination} from './ReactFlightDOMRelayServerHostConfig'; import {createRequest, startWork} from 'react-server/src/ReactFlightServer'; -type EncodedData = Array; - -function render(model: ReactModel): EncodedData { - let data: EncodedData = []; - let request = createRequest(model, data); +function render(model: ReactModel, destination: Destination): void { + let request = createRequest(model, destination); startWork(request); - return data; } export {render}; diff --git a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 5ede8b9503fed..bd6166ca95d99 100644 --- a/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -7,7 +7,88 @@ * @flow */ -export type Destination = Array; +import type {Request, ReactModel} from 'react-server/src/ReactFlightServer'; + +import type {Destination} from 'ReactFlightDOMRelayServerIntegration'; + +import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; + +import {emitModel, emitError} from 'ReactFlightDOMRelayServerIntegration'; + +export type {Destination} from 'ReactFlightDOMRelayServerIntegration'; + +type JSONValue = + | string + | number + | boolean + | null + | {[key: string]: JSONValue} + | Array; + +export type Chunk = + | { + type: 'json', + id: number, + json: JSONValue, + } + | { + type: 'error', + id: number, + json: { + message: string, + stack: string, + ... + }, + }; + +export function processErrorChunk( + request: Request, + id: number, + message: string, + stack: string, +): Chunk { + return { + type: 'error', + id: id, + json: { + message, + stack, + }, + }; +} + +function convertModelToJSON(request: Request, model: ReactModel): JSONValue { + let json = resolveModelToJSON(request, model); + if (typeof json === 'object' && json !== null) { + if (Array.isArray(json)) { + let jsonArray: Array = []; + for (let i = 0; i < json.length; i++) { + jsonArray[i] = convertModelToJSON(request, json[i]); + } + return jsonArray; + } else { + let jsonObj: {[key: string]: JSONValue} = {}; + for (let key in json) { + jsonObj[key] = convertModelToJSON(request, json[key]); + } + return jsonObj; + } + } + return json; +} + +export function processModelChunk( + request: Request, + id: number, + model: ReactModel, +): Chunk { + let json = convertModelToJSON(request, model); + return { + type: 'json', + id: id, + json: json, + }; +} export function scheduleWork(callback: () => void) { callback(); @@ -17,18 +98,15 @@ export function flushBuffered(destination: Destination) {} export function beginWriting(destination: Destination) {} -export function writeChunk( - destination: Destination, - buffer: Uint8Array, -): boolean { - destination.push(Buffer.from((buffer: any)).toString('utf8')); +export function writeChunk(destination: Destination, chunk: Chunk): boolean { + if (chunk.type === 'json') { + emitModel(destination, chunk.id, chunk.json); + } else { + emitError(destination, chunk.id, chunk.json.message, chunk.json.stack); + } return true; } export function completeWriting(destination: Destination) {} -export function close(destination: Destination) {} - -export function convertStringToBuffer(content: string): Uint8Array { - return Buffer.from(content, 'utf8'); -} +export {close} from 'ReactFlightDOMRelayServerIntegration'; diff --git a/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js new file mode 100644 index 0000000000000..212586b30b16e --- /dev/null +++ b/packages/react-flight-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const ReactFlightDOMRelayServerIntegration = { + emitModel(destination, id, json) { + destination.push({ + type: 'json', + id: id, + json: json, + }); + }, + emitError(destination, id, message, stack) { + destination.push({ + type: 'error', + id: id, + json: {message, stack}, + }); + }, + close(destination) {}, +}; + +module.exports = ReactFlightDOMRelayServerIntegration; diff --git a/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js b/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js index 2c109eedaf87e..28395a18e2c70 100644 --- a/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js +++ b/packages/react-flight-dom-relay/src/__tests__/ReactFlightDOMRelay-test.internal.js @@ -7,9 +7,6 @@ 'use strict'; -// Polyfills for test environment -global.TextDecoder = require('util').TextDecoder; - let React; let ReactDOMFlightRelayServer; let ReactDOMFlightRelayClient; @@ -32,11 +29,30 @@ describe('ReactFlightDOMRelay', () => { bar: [, ], }; } - let data = ReactDOMFlightRelayServer.render({ - foo: , - }); - let root = ReactDOMFlightRelayClient.read(data); - let model = root.model; + let data = []; + ReactDOMFlightRelayServer.render( + { + foo: , + }, + data, + ); + + let response = ReactDOMFlightRelayClient.createResponse(); + for (let i = 0; i < data.length; i++) { + let chunk = data[i]; + if (chunk.type === 'json') { + ReactDOMFlightRelayClient.resolveModel(response, chunk.id, chunk.json); + } else { + ReactDOMFlightRelayClient.resolveError( + response, + chunk.id, + chunk.json.message, + chunk.json.stack, + ); + } + } + let model = ReactDOMFlightRelayClient.getModelRoot(response).model; + ReactDOMFlightRelayClient.close(response); expect(model).toEqual({foo: {bar: ['A', 'B']}}); }); }); diff --git a/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js b/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js index 6a3e42e60ac09..38bdec83baac2 100644 --- a/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js +++ b/packages/react-flight-dom-webpack/src/ReactFlightDOMClient.js @@ -7,7 +7,7 @@ * @flow */ -import type {ReactModelRoot} from 'react-client/src/ReactFlightClient'; +import type {ReactModelRoot} from 'react-client/src/ReactFlightClientStream'; import { createResponse, @@ -15,14 +15,14 @@ import { reportGlobalError, processStringChunk, processBinaryChunk, - complete, -} from 'react-client/src/ReactFlightClient'; + close, +} from 'react-client/src/ReactFlightClientStream'; function startReadingFromStream(response, stream: ReadableStream): void { let reader = stream.getReader(); function progress({done, value}) { if (done) { - complete(response); + close(response); return; } let buffer: Uint8Array = (value: any); @@ -36,7 +36,7 @@ function startReadingFromStream(response, stream: ReadableStream): void { } function readFromReadableStream(stream: ReadableStream): ReactModelRoot { - let response = createResponse(stream); + let response = createResponse(); startReadingFromStream(response, stream); return getModelRoot(response); } @@ -44,7 +44,7 @@ function readFromReadableStream(stream: ReadableStream): ReactModelRoot { function readFromFetch( promiseForResponse: Promise, ): ReactModelRoot { - let response = createResponse(promiseForResponse); + let response = createResponse(); promiseForResponse.then( function(r) { startReadingFromStream(response, (r.body: any)); @@ -57,7 +57,7 @@ function readFromFetch( } function readFromXHR(request: XMLHttpRequest): ReactModelRoot { - let response = createResponse(request); + let response = createResponse(); let processedLength = 0; function progress(e: ProgressEvent): void { let chunk = request.responseText; @@ -66,7 +66,7 @@ function readFromXHR(request: XMLHttpRequest): ReactModelRoot { } function load(e: ProgressEvent): void { progress(e); - complete(response); + close(response); } function error(e: ProgressEvent): void { reportGlobalError(response, new TypeError('Network error')); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index 99a062017b812..51c5d73fc3c37 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -24,7 +24,7 @@ const { createResponse, getModelRoot, processStringChunk, - complete, + close, } = ReactFlightClient({ supportsBinaryStreams: false, }); @@ -34,7 +34,7 @@ function read(source: Source): ReactModelRoot { for (let i = 0; i < source.length; i++) { processStringChunk(response, source[i], 0); } - complete(response); + close(response); return getModelRoot(response); } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 3cc0733598605..5fba89e63d4ab 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -7,7 +7,7 @@ * @flow */ -import type {Destination} from './ReactServerStreamConfig'; +import type {Destination, Chunk} from './ReactFlightServerConfig'; import { scheduleWork, @@ -16,110 +16,52 @@ import { completeWriting, flushBuffered, close, - convertStringToBuffer, -} from './ReactServerStreamConfig'; + processModelChunk, + processErrorChunk, +} from './ReactFlightServerConfig'; import {renderHostChildrenToString} from './ReactServerFormatConfig'; import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; -/* - -FLIGHT PROTOCOL GRAMMAR - -Response -- JSONData RowSequence -- JSONData - -RowSequence -- Row RowSequence -- Row - -Row -- "J" RowID JSONData -- "H" RowID HTMLData -- "B" RowID BlobData -- "U" RowID URLData -- "E" RowID ErrorData - -RowID -- HexDigits ":" - -HexDigits -- HexDigit HexDigits -- HexDigit - -HexDigit -- 0-F - -URLData -- (UTF8 encoded URL) "\n" - -ErrorData -- (UTF8 encoded JSON: {message: "...", stack: "..."}) "\n" - -JSONData -- (UTF8 encoded JSON) "\n" - - String values that begin with $ are escaped with a "$" prefix. - - References to other rows are encoding as JSONReference strings. - -JSONReference -- "$" HexDigits - -HTMLData -- ByteSize (UTF8 encoded HTML) - -BlobData -- ByteSize (Binary Data) - -ByteSize -- (unsigned 32-bit integer) -*/ - -// TODO: Implement HTMLData, BlobData and URLData. - -const stringify = JSON.stringify; - -export type ReactModel = - | React$Element +type ReactJSONValue = | string | boolean | number | null - | Iterable + | Array | ReactModelObject; -type ReactJSONValue = +export type ReactModel = + | React$Element | string | boolean | number | null - | Array + | Iterable | ReactModelObject; -type ReactModelObject = {+[key: string]: ReactModel, ...}; +type ReactModelObject = {+[key: string]: ReactModel}; type Segment = { id: number, model: ReactModel, ping: () => void, - ... }; -type OpaqueRequest = { +export type Request = { destination: Destination, nextChunkId: number, pendingChunks: number, pingedSegments: Array, - completedJSONChunks: Array, - completedErrorChunks: Array, + completedJSONChunks: Array, + completedErrorChunks: Array, flowing: boolean, toJSON: (key: string, value: ReactModel) => ReactJSONValue, - ... }; export function createRequest( model: ReactModel, destination: Destination, -): OpaqueRequest { +): Request { let pingedSegments = []; let request = { destination, @@ -152,7 +94,7 @@ function attemptResolveModelComponent(element: React$Element): ReactModel { } } -function pingSegment(request: OpaqueRequest, segment: Segment): void { +function pingSegment(request: Request, segment: Segment): void { let pingedSegments = request.pingedSegments; pingedSegments.push(segment); if (pingedSegments.length === 1) { @@ -160,7 +102,7 @@ function pingSegment(request: OpaqueRequest, segment: Segment): void { } } -function createSegment(request: OpaqueRequest, model: ReactModel): Segment { +function createSegment(request: Request, model: ReactModel): Segment { let id = request.nextChunkId++; let segment = { id, @@ -174,10 +116,6 @@ function serializeIDRef(id: number): string { return '$' + id.toString(16); } -function serializeRowHeader(tag: string, id: number) { - return tag + id.toString(16) + ':'; -} - function escapeStringValue(value: string): string { if (value[0] === '$') { // We need to escape $ prefixed strings since we use that to encode @@ -188,8 +126,8 @@ function escapeStringValue(value: string): string { } } -function resolveModelToJSON( - request: OpaqueRequest, +export function resolveModelToJSON( + request: Request, value: ReactModel, ): ReactJSONValue { if (typeof value === 'string') { @@ -224,11 +162,7 @@ function resolveModelToJSON( return value; } -function emitErrorChunk( - request: OpaqueRequest, - id: number, - error: mixed, -): void { +function emitErrorChunk(request: Request, id: number, error: mixed): void { // TODO: We should not leak error messages to the client in prod. // Give this an error code instead and log on the server. // We can serialize the error in DEV as a convenience. @@ -244,12 +178,12 @@ function emitErrorChunk( } catch (x) { message = 'An error occurred but serializing the error message failed.'; } - let errorInfo = {message, stack}; - let row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n'; - request.completedErrorChunks.push(convertStringToBuffer(row)); + + let processedChunk = processErrorChunk(request, id, message, stack); + request.completedErrorChunks.push(processedChunk); } -function retrySegment(request: OpaqueRequest, segment: Segment): void { +function retrySegment(request: Request, segment: Segment): void { let value = segment.model; try { while ( @@ -263,15 +197,8 @@ function retrySegment(request: OpaqueRequest, segment: Segment): void { segment.model = element; value = attemptResolveModelComponent(element); } - let json = stringify(value, request.toJSON); - let row; - let id = segment.id; - if (id === 0) { - row = json + '\n'; - } else { - row = serializeRowHeader('J', id) + json + '\n'; - } - request.completedJSONChunks.push(convertStringToBuffer(row)); + let processedChunk = processModelChunk(request, segment.id, value); + request.completedJSONChunks.push(processedChunk); } catch (x) { if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // Something suspended again, let's pick it back up later. @@ -285,7 +212,7 @@ function retrySegment(request: OpaqueRequest, segment: Segment): void { } } -function performWork(request: OpaqueRequest): void { +function performWork(request: Request): void { let pingedSegments = request.pingedSegments; request.pingedSegments = []; for (let i = 0; i < pingedSegments.length; i++) { @@ -298,7 +225,7 @@ function performWork(request: OpaqueRequest): void { } let reentrant = false; -function flushCompletedChunks(request: OpaqueRequest): void { +function flushCompletedChunks(request: Request): void { if (reentrant) { return; } @@ -341,12 +268,12 @@ function flushCompletedChunks(request: OpaqueRequest): void { } } -export function startWork(request: OpaqueRequest): void { +export function startWork(request: Request): void { request.flowing = true; scheduleWork(() => performWork(request)); } -export function startFlowing(request: OpaqueRequest): void { +export function startFlowing(request: Request): void { request.flowing = true; flushCompletedChunks(request); } diff --git a/packages/react-server/src/ReactFlightServerConfig.js b/packages/react-server/src/ReactFlightServerConfig.js new file mode 100644 index 0000000000000..49c9752540528 --- /dev/null +++ b/packages/react-server/src/ReactFlightServerConfig.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/* eslint-disable react-internal/invariant-args */ + +import invariant from 'shared/invariant'; + +// We expect that our Rollup, Jest, and Flow configurations +// always shim this module with the corresponding host config +// (either provided by a renderer, or a generic shim for npm). +// +// We should never resolve to this file, but it exists to make +// sure that if we *do* accidentally break the configuration, +// the failure isn't silent. + +invariant(false, 'This module must be shimmed by a specific renderer.'); diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js new file mode 100644 index 0000000000000..dc7dece497396 --- /dev/null +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -0,0 +1,114 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// This file is an intermediate layer to translate between Flight +// calls to stream output over a binary stream. + +/* +FLIGHT PROTOCOL GRAMMAR + +Response +- JSONData RowSequence +- JSONData + +RowSequence +- Row RowSequence +- Row + +Row +- "J" RowID JSONData +- "H" RowID HTMLData +- "B" RowID BlobData +- "U" RowID URLData +- "E" RowID ErrorData + +RowID +- HexDigits ":" + +HexDigits +- HexDigit HexDigits +- HexDigit + +HexDigit +- 0-F + +URLData +- (UTF8 encoded URL) "\n" + +ErrorData +- (UTF8 encoded JSON: {message: "...", stack: "..."}) "\n" + +JSONData +- (UTF8 encoded JSON) "\n" + - String values that begin with $ are escaped with a "$" prefix. + - References to other rows are encoding as JSONReference strings. + +JSONReference +- "$" HexDigits + +HTMLData +- ByteSize (UTF8 encoded HTML) + +BlobData +- ByteSize (Binary Data) + +ByteSize +- (unsigned 32-bit integer) +*/ + +// TODO: Implement HTMLData, BlobData and URLData. + +import type {Request, ReactModel} from 'react-server/src/ReactFlightServer'; + +import {convertStringToBuffer} from './ReactServerStreamConfig'; + +export type {Destination} from './ReactServerStreamConfig'; + +export type Chunk = Uint8Array; + +const stringify = JSON.stringify; + +function serializeRowHeader(tag: string, id: number) { + return tag + id.toString(16) + ':'; +} + +export function processErrorChunk( + request: Request, + id: number, + message: string, + stack: string, +): Chunk { + let errorInfo = {message, stack}; + let row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n'; + return convertStringToBuffer(row); +} + +export function processModelChunk( + request: Request, + id: number, + model: ReactModel, +): Chunk { + let json = stringify(model, request.toJSON); + let row; + if (id === 0) { + row = json + '\n'; + } else { + row = serializeRowHeader('J', id) + json + '\n'; + } + return convertStringToBuffer(row); +} + +export { + scheduleWork, + flushBuffered, + beginWriting, + writeChunk, + completeWriting, + close, +} from './ReactServerStreamConfig'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js new file mode 100644 index 0000000000000..2ade60a042904 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from '../ReactFlightServerConfigStream'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js new file mode 100644 index 0000000000000..2ade60a042904 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-browser.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from '../ReactFlightServerConfigStream'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js new file mode 100644 index 0000000000000..e283cca637c43 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-relay.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from 'react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig'; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom.js new file mode 100644 index 0000000000000..2ade60a042904 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from '../ReactFlightServerConfigStream'; diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js index e283cca637c43..7b6480120ee82 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-relay.js @@ -7,4 +7,4 @@ * @flow */ -export * from 'react-flight-dom-relay/src/ReactFlightDOMRelayServerHostConfig'; +export * from '../ReactServerStreamConfigNode'; diff --git a/scripts/flow/config/flowconfig b/scripts/flow/config/flowconfig index 4790c78cf38e3..9d7f15c98a053 100644 --- a/scripts/flow/config/flowconfig +++ b/scripts/flow/config/flowconfig @@ -29,6 +29,7 @@ ../environment.js ../react-devtools.js ../react-native-host-hooks.js +../react-relay-hooks.js [lints] untyped-type-import=error diff --git a/scripts/flow/createFlowConfigs.js b/scripts/flow/createFlowConfigs.js index 434368af00424..6f57c2f58e7a9 100644 --- a/scripts/flow/createFlowConfigs.js +++ b/scripts/flow/createFlowConfigs.js @@ -51,6 +51,7 @@ function writeConfig(renderer, rendererInfo, isServerSupported) { module.name_mapper='ReactFiberHostConfig$$' -> 'forks/ReactFiberHostConfig.${renderer}' module.name_mapper='ReactServerStreamConfig$$' -> 'forks/ReactServerStreamConfig.${serverRenderer}' module.name_mapper='ReactServerFormatConfig$$' -> 'forks/ReactServerFormatConfig.${serverRenderer}' +module.name_mapper='ReactFlightServerConfig$$' -> 'forks/ReactFlightServerConfig.${serverRenderer}' module.name_mapper='ReactFlightClientHostConfig$$' -> 'forks/ReactFlightClientHostConfig.${serverRenderer}' `.trim(), ) diff --git a/scripts/flow/react-relay-hooks.js b/scripts/flow/react-relay-hooks.js new file mode 100644 index 0000000000000..0b42a2572340b --- /dev/null +++ b/scripts/flow/react-relay-hooks.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +type JSONValue = + | string + | number + | boolean + | null + | {[key: string]: JSONValue} + | Array; + +declare module 'ReactFlightDOMRelayServerIntegration' { + declare export opaque type Destination; + declare export function emitModel( + destination: Destination, + id: number, + json: JSONValue, + ): void; + declare export function emitError( + destination: Destination, + id: number, + message: string, + stack: string, + ): void; + declare export function close(destination: Destination): void; +} diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index d11330db5522b..7ec000b847228 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -13,6 +13,7 @@ jest.mock('react-reconciler', () => { }); const shimServerStreamConfigPath = 'react-server/src/ReactServerStreamConfig'; const shimServerFormatConfigPath = 'react-server/src/ReactServerFormatConfig'; +const shimFlightServerConfigPath = 'react-server/src/ReactFlightServerConfig'; jest.mock('react-server', () => { return config => { jest.mock(shimServerStreamConfigPath, () => config); @@ -24,6 +25,11 @@ jest.mock('react-server/flight', () => { return config => { jest.mock(shimServerStreamConfigPath, () => config); jest.mock(shimServerFormatConfigPath, () => config); + jest.mock(shimFlightServerConfigPath, () => + require.requireActual( + 'react-server/src/forks/ReactFlightServerConfig.custom' + ) + ); return require.requireActual('react-server/flight'); }; }); @@ -41,6 +47,7 @@ const configPaths = [ 'react-client/src/ReactFlightClientHostConfig', 'react-server/src/ReactServerStreamConfig', 'react-server/src/ReactServerFormatConfig', + 'react-server/src/ReactFlightServerConfig', ]; function mockAllConfigs(rendererInfo) { diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 7a355bc46d33f..767d2600cb5a2 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -203,7 +203,11 @@ const bundles = [ moduleType: RENDERER, entry: 'react-flight-dom-relay/server', global: 'ReactFlightDOMRelayServer', - externals: ['react', 'react-dom/server'], + externals: [ + 'react', + 'react-dom/server', + 'ReactFlightDOMRelayServerIntegration', + ], }, /******* React DOM Flight Client Relay *******/ diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 105eb2585333d..35a7977093431 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -354,6 +354,34 @@ const forks = Object.freeze({ ); }, + 'react-server/src/ReactFlightServerConfig': ( + bundleType, + entry, + dependencies, + moduleType + ) => { + if (dependencies.indexOf('react-server') !== -1) { + return null; + } + if (moduleType !== RENDERER && moduleType !== RECONCILER) { + return null; + } + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (let rendererInfo of inlinedHostConfigs) { + if (rendererInfo.entryPoints.indexOf(entry) !== -1) { + if (!rendererInfo.isServerSupported) { + return null; + } + return `react-server/src/forks/ReactFlightServerConfig.${rendererInfo.shortName}.js`; + } + } + throw new Error( + 'Expected ReactFlightServerConfig to always be replaced with a shim, but ' + + `found no mention of "${entry}" entry point in ./scripts/shared/inlinedHostConfigs.js. ` + + 'Did you mean to add it there to associate it with a specific renderer?' + ); + }, + 'react-client/src/ReactFlightClientHostConfig': ( bundleType, entry,