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,