Skip to content

Commit

Permalink
Split out Edge and Node implementations of the Flight Client (#26187)
Browse files Browse the repository at this point in the history
This splits out the Edge and Node implementations of Flight Client into
their own implementations. The Node implementation now takes a Node
Stream as input.

I removed the bundler config from the Browser variant because you're
never supposed to use that in the browser since it's only for SSR.
Similarly, it's required on the server. This also enables generating a
SSR manifest from the Webpack plugin. This is necessary for SSR so that
you can reverse look up what a client module is called on the server.

I also removed the option to pass a callServer from the server. We might
want to add it back in the future but basically, we don't recommend
calling Server Functions from render for initial render because if that
happened client-side it would be a client-side waterfall. If it's never
called in initial render, then it also shouldn't ever happen during SSR.
This might be considered too restrictive.

~This also compiles the unbundled packages as ESM. This isn't strictly
necessary because we only need access to dynamic import to load the
modules but we don't have any other build options that leave
`import(...)` intact, and seems appropriate that this would also be an
ESM module.~ Went with `import(...)` in CJS instead.
  • Loading branch information
sebmarkbage committed Feb 21, 2023
1 parent 70b0bbd commit 60144a0
Show file tree
Hide file tree
Showing 28 changed files with 657 additions and 124 deletions.
25 changes: 21 additions & 4 deletions fixtures/flight/loader/index.js
Expand Up @@ -23,8 +23,17 @@ async function babelLoad(url, context, defaultLoad) {
const result = await defaultLoad(url, context, defaultLoad);
if (result.format === 'module') {
const opt = Object.assign({filename: url}, babelOptions);
const {code} = await babel.transformAsync(result.source, opt);
return {source: code, format: 'module'};
const newResult = await babel.transformAsync(result.source, opt);
if (!newResult) {
if (typeof result.source === 'string') {
return result;
}
return {
source: Buffer.from(result.source).toString('utf8'),
format: 'module',
};
}
return {source: newResult.code, format: 'module'};
}
return defaultLoad(url, context, defaultLoad);
}
Expand All @@ -39,8 +48,16 @@ async function babelTransformSource(source, context, defaultTransformSource) {
const {format} = context;
if (format === 'module') {
const opt = Object.assign({filename: context.url}, babelOptions);
const {code} = await babel.transformAsync(source, opt);
return {source: code};
const newResult = await babel.transformAsync(source, opt);
if (!newResult) {
if (typeof source === 'string') {
return {source};
}
return {
source: Buffer.from(source).toString('utf8'),
};
}
return {source: newResult.code};
}
return defaultTransformSource(source, context, defaultTransformSource);
}
Expand Down
4 changes: 3 additions & 1 deletion fixtures/flight/server/handler.js
@@ -1,11 +1,13 @@
'use strict';

const {renderToPipeableStream} = require('react-server-dom-webpack/server');
const {readFile} = require('fs').promises;
const {resolve} = require('path');
const React = require('react');

module.exports = async function (req, res) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
switch (req.method) {
case 'POST': {
const serverReference = JSON.parse(req.get('rsc-action'));
Expand Down
34 changes: 34 additions & 0 deletions packages/react-client/src/ReactFlightClientHostConfigNode.js
@@ -0,0 +1,34 @@
/**
* 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 {TextDecoder} from 'util';

export type StringDecoder = TextDecoder;

export const supportsBinaryStreams = true;

export function createStringDecoder(): StringDecoder {
return new TextDecoder();
}

const decoderOptions = {stream: true};

export function readPartialStringChunk(
decoder: StringDecoder,
buffer: Uint8Array,
): string {
return decoder.decode(buffer, decoderOptions);
}

export function readFinalStringChunk(
decoder: StringDecoder,
buffer: Uint8Array,
): string {
return decoder.decode(buffer);
}
Expand Up @@ -7,6 +7,6 @@
* @flow
*/

export * from 'react-client/src/ReactFlightClientHostConfigBrowser';
export * from 'react-client/src/ReactFlightClientHostConfigNode';
export * from 'react-client/src/ReactFlightClientHostConfigStream';
export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig';
Expand Up @@ -7,6 +7,6 @@
* @flow
*/

export * from 'react-client/src/ReactFlightClientHostConfigBrowser';
export * from 'react-client/src/ReactFlightClientHostConfigNode';
export * from 'react-client/src/ReactFlightClientHostConfigStream';
export * from 'react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig';
export * from 'react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig';
2 changes: 1 addition & 1 deletion packages/react-server-dom-webpack/client.browser.js
Expand Up @@ -7,4 +7,4 @@
* @flow
*/

export * from './src/ReactFlightDOMClient';
export * from './src/ReactFlightDOMClientBrowser';
2 changes: 1 addition & 1 deletion packages/react-server-dom-webpack/client.edge.js
Expand Up @@ -7,4 +7,4 @@
* @flow
*/

export * from './src/ReactFlightDOMClient';
export * from './src/ReactFlightDOMClientEdge';
2 changes: 1 addition & 1 deletion packages/react-server-dom-webpack/client.node.js
Expand Up @@ -7,4 +7,4 @@
* @flow
*/

export * from './src/ReactFlightDOMClient';
export * from './src/ReactFlightDOMClientNode';
2 changes: 1 addition & 1 deletion packages/react-server-dom-webpack/client.node.unbundled.js
Expand Up @@ -7,4 +7,4 @@
* @flow
*/

export * from './src/ReactFlightDOMClient';
export * from './src/ReactFlightDOMClientNode';
2 changes: 1 addition & 1 deletion packages/react-server-dom-webpack/package.json
Expand Up @@ -64,7 +64,7 @@
"./server.edge": "./server.edge.js",
"./server.node": "./server.node.js",
"./server.node.unbundled": "./server.node.unbundled.js",
"./node-loader": "./esm/react-server-dom-webpack-node-loader.js",
"./node-loader": "./esm/react-server-dom-webpack-node-loader.production.min.js",
"./node-register": "./node-register.js",
"./src/*": "./src/*",
"./package.json": "./package.json"
Expand Down
@@ -0,0 +1,98 @@
/**
* 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 {
Thenable,
FulfilledThenable,
RejectedThenable,
} from 'shared/ReactTypes';

export type WebpackSSRMap = {
[clientId: string]: {
[clientExportName: string]: ClientReference<any>,
},
};

export type BundlerConfig = WebpackSSRMap;

export opaque type ClientReferenceMetadata = {
id: string,
chunks: Array<string>,
name: string,
async: boolean,
};

// eslint-disable-next-line no-unused-vars
export opaque type ClientReference<T> = {
specifier: string,
name: string,
};

export function resolveClientReference<T>(
bundlerConfig: BundlerConfig,
metadata: ClientReferenceMetadata,
): ClientReference<T> {
const resolvedModuleData = bundlerConfig[metadata.id][metadata.name];
return resolvedModuleData;
}

const asyncModuleCache: Map<string, Thenable<any>> = new Map();

export function preloadModule<T>(
metadata: ClientReference<T>,
): null | Thenable<any> {
const existingPromise = asyncModuleCache.get(metadata.specifier);
if (existingPromise) {
if (existingPromise.status === 'fulfilled') {
return null;
}
return existingPromise;
} else {
// $FlowFixMe[unsupported-syntax]
const modulePromise: Thenable<T> = import(metadata.specifier);
modulePromise.then(
value => {
const fulfilledThenable: FulfilledThenable<mixed> =
(modulePromise: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = value;
},
reason => {
const rejectedThenable: RejectedThenable<mixed> = (modulePromise: any);
rejectedThenable.status = 'rejected';
rejectedThenable.reason = reason;
},
);
asyncModuleCache.set(metadata.specifier, modulePromise);
return modulePromise;
}
}

export function requireModule<T>(metadata: ClientReference<T>): T {
let moduleExports;
// We assume that preloadModule has been called before, which
// should have added something to the module cache.
const promise: any = asyncModuleCache.get(metadata.specifier);
if (promise.status === 'fulfilled') {
moduleExports = promise.value;
} else {
throw promise.reason;
}
if (metadata.name === '*') {
// This is a placeholder value that represents that the caller imported this
// as a CommonJS module as is.
return moduleExports;
}
if (metadata.name === '') {
// This is a placeholder value that represents that the caller accessed the
// default property of this if it was an ESM interop module.
return moduleExports.default;
}
return moduleExports[metadata.name];
}
Expand Up @@ -11,8 +11,6 @@ import type {Thenable} from 'shared/ReactTypes.js';

import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream';

import type {BundlerConfig} from './ReactFlightClientWebpackBundlerConfig';

import {
createResponse,
getRoot,
Expand All @@ -28,10 +26,16 @@ type CallServerCallback = <A, T>(
) => Promise<T>;

export type Options = {
moduleMap?: BundlerConfig,
callServer?: CallServerCallback,
};

function createResponseFromOptions(options: void | Options) {
return createResponse(
null,
options && options.callServer ? options.callServer : undefined,
);
}

function startReadingFromStream(
response: FlightResponse,
stream: ReadableStream,
Expand Down Expand Up @@ -63,10 +67,7 @@ function createFromReadableStream<T>(
stream: ReadableStream,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponse(
options && options.moduleMap ? options.moduleMap : null,
options && options.callServer ? options.callServer : undefined,
);
const response: FlightResponse = createResponseFromOptions(options);
startReadingFromStream(response, stream);
return getRoot(response);
}
Expand All @@ -75,10 +76,7 @@ function createFromFetch<T>(
promiseForResponse: Promise<Response>,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponse(
options && options.moduleMap ? options.moduleMap : null,
options && options.callServer ? options.callServer : undefined,
);
const response: FlightResponse = createResponseFromOptions(options);
promiseForResponse.then(
function (r) {
startReadingFromStream(response, (r.body: any));
Expand All @@ -94,10 +92,7 @@ function createFromXHR<T>(
request: XMLHttpRequest,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponse(
options && options.moduleMap ? options.moduleMap : null,
options && options.callServer ? options.callServer : undefined,
);
const response: FlightResponse = createResponseFromOptions(options);
let processedLength = 0;
function progress(e: ProgressEvent): void {
const chunk = request.responseText;
Expand Down
95 changes: 95 additions & 0 deletions packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js
@@ -0,0 +1,95 @@
/**
* 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 {Thenable} from 'shared/ReactTypes.js';

import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream';

import type {BundlerConfig} from './ReactFlightClientWebpackBundlerConfig';

import {
createResponse,
getRoot,
reportGlobalError,
processBinaryChunk,
close,
} from 'react-client/src/ReactFlightClientStream';

function noServerCall() {
throw new Error(
'Server Functions cannot be called during initial render. ' +
'This would create a fetch waterfall. Try to use a Server Component ' +
'to pass data to Client Components instead.',
);
}

export type Options = {
moduleMap?: BundlerConfig,
};

function createResponseFromOptions(options: void | Options) {
return createResponse(
options && options.moduleMap ? options.moduleMap : null,
noServerCall,
);
}

function startReadingFromStream(
response: FlightResponse,
stream: ReadableStream,
): void {
const reader = stream.getReader();
function progress({
done,
value,
}: {
done: boolean,
value: ?any,
...
}): void | Promise<void> {
if (done) {
close(response);
return;
}
const buffer: Uint8Array = (value: any);
processBinaryChunk(response, buffer);
return reader.read().then(progress).catch(error);
}
function error(e: any) {
reportGlobalError(response, e);
}
reader.read().then(progress).catch(error);
}

function createFromReadableStream<T>(
stream: ReadableStream,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponseFromOptions(options);
startReadingFromStream(response, stream);
return getRoot(response);
}

function createFromFetch<T>(
promiseForResponse: Promise<Response>,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponseFromOptions(options);
promiseForResponse.then(
function (r) {
startReadingFromStream(response, (r.body: any));
},
function (e) {
reportGlobalError(response, e);
},
);
return getRoot(response);
}

export {createFromFetch, createFromReadableStream};

0 comments on commit 60144a0

Please sign in to comment.