Enable passing Server References from Server to Client (#26124)

This is the first of a series of PRs, that let you pass functions, by
reference, to the client and back. E.g. through Server Context. It's
like client references but they're opaque on the client and resolved on
the server.

To do this, for security, you must opt-in to exposing these functions to
the client using the `"use server"` directive. The `"use client"`
directive lets you enter the client from the server. The `"use server"`
directive lets you enter the server from the client.

This works by tagging those functions as Server References. We could
potentially expand this to other non-serializable or stateful objects
too like classes.

This only implements server->server CJS imports and server->server ESM
imports. We really should add a loader to the webpack plug-in for
client->server imports too. I'll leave closures as an exercise for
integrators.

You can't "call" a client reference on the server, however, you can
"call" a server reference on the client. This invokes a callback on the
Flight client options called `callServer`. This lets a router implement
calling back to the server. Effectively creating an RPC. This is using
JSON for serializing those arguments but more utils coming from
client->server serialization.
This commit is contained in:
Sebastian Markbåge 2023-02-09 19:45:05 -05:00 committed by GitHub
parent 6c75d4e009
commit ef9f6e77b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 844 additions and 219 deletions

View File

@ -16,6 +16,7 @@
"babel-plugin-named-asset-import": "^0.3.8",
"babel-preset-react-app": "^10.0.1",
"bfj": "^7.0.2",
"body-parser": "^1.20.1",
"browserslist": "^4.18.1",
"camelcase": "^6.2.1",
"case-sensitive-paths-webpack-plugin": "^2.4.0",

View File

@ -25,6 +25,7 @@ babelRegister({
});
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// Application
@ -32,6 +33,17 @@ app.get('/', function (req, res) {
require('./handler.js')(req, res);
});
app.options('/', function (req, res) {
res.setHeader('Allow', 'Allow: GET,HEAD,POST');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'rsc-action');
res.end();
});
app.post('/', bodyParser.text(), function (req, res) {
require('./handler.js')(req, res);
});
app.get('/todos', function (req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.json([

View File

@ -1,31 +1,49 @@
'use strict';
const {renderToPipeableStream} = require('react-server-dom-webpack/server');
const {readFile} = require('fs');
const {readFile} = require('fs').promises;
const {resolve} = require('path');
const React = require('react');
module.exports = function (req, res) {
// const m = require('../src/App.js');
import('../src/App.js').then(m => {
const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build';
readFile(
resolve(__dirname, `../${dist}/react-client-manifest.json`),
'utf8',
(err, data) => {
if (err) {
throw err;
}
const App = m.default.default || m.default;
res.setHeader('Access-Control-Allow-Origin', '*');
const moduleMap = JSON.parse(data);
const {pipe} = renderToPipeableStream(
React.createElement(App),
moduleMap
);
pipe(res);
module.exports = async function (req, res) {
switch (req.method) {
case 'POST': {
const serverReference = JSON.parse(req.get('rsc-action'));
const {filepath, name} = serverReference;
const action = (await import(filepath))[name];
// Validate that this is actually a function we intended to expose and
// not the client trying to invoke arbitrary functions. In a real app,
// you'd have a manifest verifying this before even importing it.
if (action.$$typeof !== Symbol.for('react.server.reference')) {
throw new Error('Invalid action');
}
);
});
const args = JSON.parse(req.body);
const result = action.apply(null, args);
res.setHeader('Access-Control-Allow-Origin', '*');
const {pipe} = renderToPipeableStream(result, {});
pipe(res);
return;
}
default: {
// const m = require('../src/App.js');
const m = await import('../src/App.js');
const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build';
const data = await readFile(
resolve(__dirname, `../${dist}/react-client-manifest.json`),
'utf8'
);
const App = m.default.default || m.default;
res.setHeader('Access-Control-Allow-Origin', '*');
const moduleMap = JSON.parse(data);
const {pipe} = renderToPipeableStream(
React.createElement(App),
moduleMap
);
pipe(res);
return;
}
}
};

View File

@ -6,6 +6,9 @@ import {Counter} from './Counter.js';
import {Counter as Counter2} from './Counter2.js';
import ShowMore from './ShowMore.js';
import Button from './Button.js';
import {like} from './actions.js';
export default async function App() {
const res = await fetch('http://localhost:3001/todos');
@ -23,6 +26,9 @@ export default async function App() {
<ShowMore>
<p>Lorem ipsum</p>
</ShowMore>
<div>
<Button action={like}>Like</Button>
</div>
</Container>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import * as React from 'react';
export default function Button({action, children}) {
return (
<button
onClick={async () => {
const result = await action();
console.log(result);
}}>
{children}
</button>
);
}

View File

@ -0,0 +1,6 @@
'use server';
export async function like() {
console.log('Like');
return 'Liked';
}

View File

@ -3,7 +3,22 @@ import {Suspense} from 'react';
import ReactDOM from 'react-dom/client';
import ReactServerDOMReader from 'react-server-dom-webpack/client';
let data = ReactServerDOMReader.createFromFetch(fetch('http://localhost:3001'));
let data = ReactServerDOMReader.createFromFetch(
fetch('http://localhost:3001'),
{
callServer(id, args) {
const response = fetch('http://localhost:3001', {
method: 'POST',
cors: 'cors',
headers: {
'rsc-action': JSON.stringify({filepath: id.id, name: id.name}),
},
body: JSON.stringify(args),
});
return ReactServerDOMReader.createFromFetch(response);
},
}
);
function Content() {
return React.use(data);

View File

@ -3221,6 +3221,24 @@ body-parser@1.20.0:
type-is "~1.6.18"
unpipe "1.0.0"
body-parser@^1.20.1:
version "1.20.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==
dependencies:
bytes "3.1.2"
content-type "~1.0.4"
debug "2.6.9"
depd "2.0.0"
destroy "1.2.0"
http-errors "2.0.0"
iconv-lite "0.4.24"
on-finished "2.4.1"
qs "6.11.0"
raw-body "2.5.1"
type-is "~1.6.18"
unpipe "1.0.0"
bonjour-service@^1.0.11:
version "1.0.13"
resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.0.13.tgz#4ac003dc1626023252d58adf2946f57e5da450c1"
@ -7970,6 +7988,13 @@ qs@6.10.3:
dependencies:
side-channel "^1.0.4"
qs@6.11.0:
version "6.11.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
dependencies:
side-channel "^1.0.4"
quick-lru@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"

View File

@ -12,7 +12,7 @@ import type {LazyComponent} from 'react/src/ReactLazy';
import type {
ClientReference,
ModuleMetaData,
ClientReferenceMetadata,
UninitializedModel,
Response,
BundlerConfig,
@ -29,6 +29,8 @@ import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
export type JSONValue =
| number
| null
@ -148,6 +150,7 @@ Chunk.prototype.then = function <T>(
export type ResponseBase = {
_bundlerConfig: BundlerConfig,
_callServer: CallServerCallback,
_chunks: Map<number, SomeChunk<any>>,
...
};
@ -468,6 +471,28 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
return (error: mixed) => triggerErrorOnChunk(chunk, error);
}
function createServerReferenceProxy<A: Iterable<any>, T>(
response: Response,
metaData: any,
): (...A) => Promise<T> {
const callServer = response._callServer;
const proxy = function (): Promise<T> {
// $FlowFixMe[method-unbinding]
const args = Array.prototype.slice.call(arguments);
const p = metaData.bound;
if (p.status === INITIALIZED) {
const bound = p.value;
return callServer(metaData, bound.concat(args));
}
// Since this is a fake Promise whose .then doesn't chain, we have to wrap it.
// TODO: Remove the wrapper once that's fixed.
return Promise.resolve(p).then(function (bound) {
return callServer(metaData, bound.concat(args));
});
};
return proxy;
}
export function parseModelString(
response: Response,
parentObject: Object,
@ -499,11 +524,33 @@ export function parseModelString(
return chunk;
}
case 'S': {
// Symbol
return Symbol.for(value.substring(2));
}
case 'P': {
// Server Context Provider
return getOrCreateServerContext(value.substring(2)).Provider;
}
case 'F': {
// Server Reference
const id = parseInt(value.substring(2), 16);
const chunk = getChunk(response, id);
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED: {
const metadata = chunk.value;
return createServerReferenceProxy(response, metadata);
}
// We always encode it first in the stream so it won't be pending.
default:
throw chunk.reason;
}
}
default: {
// We assume that anything else is a reference ID.
const id = parseInt(value.substring(1), 16);
@ -551,10 +598,21 @@ export function parseModelTuple(
return value;
}
export function createResponse(bundlerConfig: BundlerConfig): ResponseBase {
function missingCall() {
throw new Error(
'Trying to call a function from "use server" but the callServer option ' +
'was not implemented in your router runtime.',
);
}
export function createResponse(
bundlerConfig: BundlerConfig,
callServer: void | CallServerCallback,
): ResponseBase {
const chunks: Map<number, SomeChunk<any>> = new Map();
const response = {
_bundlerConfig: bundlerConfig,
_callServer: callServer !== undefined ? callServer : missingCall,
_chunks: chunks,
};
return response;
@ -581,16 +639,19 @@ export function resolveModule(
): void {
const chunks = response._chunks;
const chunk = chunks.get(id);
const moduleMetaData: ModuleMetaData = parseModel(response, model);
const moduleReference = resolveClientReference<$FlowFixMe>(
const clientReferenceMetadata: ClientReferenceMetadata = parseModel(
response,
model,
);
const clientReference = resolveClientReference<$FlowFixMe>(
response._bundlerConfig,
moduleMetaData,
clientReferenceMetadata,
);
// TODO: Add an option to encode modules that are lazy loaded.
// For now we preload all modules as early as possible since it's likely
// that we'll need them.
const promise = preloadModule(moduleReference);
const promise = preloadModule(clientReference);
if (promise) {
let blockedChunk: BlockedChunk<any>;
if (!chunk) {
@ -605,16 +666,16 @@ export function resolveModule(
blockedChunk.status = BLOCKED;
}
promise.then(
() => resolveModuleChunk(blockedChunk, moduleReference),
() => resolveModuleChunk(blockedChunk, clientReference),
error => triggerErrorOnChunk(blockedChunk, error),
);
} else {
if (!chunk) {
chunks.set(id, createResolvedModuleChunk(response, moduleReference));
chunks.set(id, createResolvedModuleChunk(response, clientReference));
} else {
// This can't actually happen because we don't have any forward
// references to modules.
resolveModuleChunk(chunk, moduleReference);
resolveModuleChunk(chunk, clientReference);
}
}
}

View File

@ -7,8 +7,8 @@
* @flow
*/
import type {CallServerCallback} from './ReactFlightClient';
import type {Response} from './ReactFlightClientHostConfigStream';
import type {BundlerConfig} from './ReactFlightClientHostConfig';
import {
@ -120,11 +120,14 @@ function createFromJSONCallback(response: Response) {
};
}
export function createResponse(bundlerConfig: BundlerConfig): Response {
export function createResponse(
bundlerConfig: BundlerConfig,
callServer: void | CallServerCallback,
): Response {
// NOTE: CHECK THE COMPILER OUTPUT EACH TIME YOU CHANGE THIS.
// It should be inlined to one object literal but minor changes can break it.
const stringDecoder = supportsBinaryStreams ? createStringDecoder() : null;
const response: any = createResponseBase(bundlerConfig);
const response: any = createResponseBase(bundlerConfig, callServer);
response._partialRow = '';
if (supportsBinaryStreams) {
response._stringDecoder = stringDecoder;

View File

@ -447,7 +447,10 @@ describe('ReactFlight', () => {
<ErrorBoundary expectedMessage="Event handlers cannot be passed to Client Component props.">
<Render promise={ReactNoopFlightClient.read(event)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Functions cannot be passed directly to Client Components because they're not serializable.">
<ErrorBoundary
expectedMessage={
'Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".'
}>
<Render promise={ReactNoopFlightClient.read(fn)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Only global symbols received from Symbol.for(...) can be passed to Client Components.">
@ -459,7 +462,10 @@ describe('ReactFlight', () => {
<ErrorBoundary expectedMessage="Event handlers cannot be passed to Client Component props.">
<Render promise={ReactNoopFlightClient.read(eventClient)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Functions cannot be passed directly to Client Components because they're not serializable.">
<ErrorBoundary
expectedMessage={
'Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".'
}>
<Render promise={ReactNoopFlightClient.read(fnClient)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Only global symbols received from Symbol.for(...) can be passed to Client Components.">

View File

@ -27,7 +27,7 @@ declare var $$$hostConfig: any;
export type Response = any;
export opaque type BundlerConfig = mixed;
export opaque type ModuleMetaData = mixed;
export opaque type ClientReferenceMetadata = mixed;
export opaque type ClientReference<T> = mixed; // eslint-disable-line no-unused-vars
export const resolveClientReference = $$$hostConfig.resolveClientReference;
export const preloadModule = $$$hostConfig.preloadModule;

View File

@ -51,10 +51,13 @@ const ReactNoopFlightServer = ReactFlightServer({
isClientReference(reference: Object): boolean {
return reference.$$typeof === Symbol.for('react.client.reference');
},
isServerReference(reference: Object): boolean {
return reference.$$typeof === Symbol.for('react.server.reference');
},
getClientReferenceKey(reference: Object): Object {
return reference;
},
resolveModuleMetaData(
resolveClientReferenceMetadata(
config: void,
reference: {$$typeof: symbol, value: any},
) {

View File

@ -11,7 +11,7 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient';
import type {JSResourceReference} from 'JSResourceReference';
import type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration';
import type {ClientReferenceMetadata} from 'ReactFlightDOMRelayClientIntegration';
export type ClientReference<T> = JSResourceReference<T>;
@ -29,7 +29,7 @@ import {resolveClientReference as resolveClientReferenceImpl} from 'ReactFlightD
import isArray from 'shared/isArray';
export type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration';
export type {ClientReferenceMetadata} from 'ReactFlightDOMRelayClientIntegration';
export type BundlerConfig = null;
@ -39,9 +39,9 @@ export type Response = ResponseBase;
export function resolveClientReference<T>(
bundlerConfig: BundlerConfig,
moduleData: ModuleMetaData,
metadata: ClientReferenceMetadata,
): ClientReference<T> {
return resolveClientReferenceImpl(moduleData);
return resolveClientReferenceImpl(metadata);
}
function parseModelRecursively(

View File

@ -7,7 +7,7 @@
* @flow
*/
import type {ModuleMetaData} from 'ReactFlightDOMRelayServerIntegration';
import type {ClientReferenceMetadata} from 'ReactFlightDOMRelayServerIntegration';
export type JSONValue =
| string
@ -19,7 +19,7 @@ export type JSONValue =
export type RowEncoding =
| ['O', number, JSONValue]
| ['I', number, ModuleMetaData]
| ['I', number, ClientReferenceMetadata]
| ['P', number, string]
| ['S', number, string]
| [

View File

@ -18,31 +18,37 @@ import hasOwnProperty from 'shared/hasOwnProperty';
import isArray from 'shared/isArray';
export type ClientReference<T> = JSResourceReference<T>;
export type ServerReference<T> = T;
export type ServerReferenceMetadata = {};
import type {
Destination,
BundlerConfig,
ModuleMetaData,
ClientReferenceMetadata,
} from 'ReactFlightDOMRelayServerIntegration';
import {resolveModelToJSON} from 'react-server/src/ReactFlightServer';
import {
emitRow,
resolveModuleMetaData as resolveModuleMetaDataImpl,
resolveClientReferenceMetadata as resolveClientReferenceMetadataImpl,
close,
} from 'ReactFlightDOMRelayServerIntegration';
export type {
Destination,
BundlerConfig,
ModuleMetaData,
ClientReferenceMetadata,
} from 'ReactFlightDOMRelayServerIntegration';
export function isClientReference(reference: Object): boolean {
return reference instanceof JSResourceReferenceImpl;
}
export function isServerReference(reference: Object): boolean {
return false;
}
export type ClientReferenceKey = ClientReference<any>;
export function getClientReferenceKey(
@ -53,11 +59,18 @@ export function getClientReferenceKey(
return reference;
}
export function resolveModuleMetaData<T>(
export function resolveClientReferenceMetadata<T>(
config: BundlerConfig,
resource: ClientReference<T>,
): ModuleMetaData {
return resolveModuleMetaDataImpl(config, resource);
): ClientReferenceMetadata {
return resolveClientReferenceMetadataImpl(config, resource);
}
export function resolveServerReferenceMetadata<T>(
config: BundlerConfig,
resource: ServerReference<T>,
): ServerReferenceMetadata {
throw new Error('Not implemented.');
}
export type Chunk = RowEncoding;
@ -162,13 +175,13 @@ export function processReferenceChunk(
return ['O', id, reference];
}
export function processModuleChunk(
export function processImportChunk(
request: Request,
id: number,
moduleMetaData: ModuleMetaData,
clientReferenceMetadata: ClientReferenceMetadata,
): Chunk {
// The moduleMetaData is already a JSON serializable value.
return ['I', id, moduleMetaData];
// The clientReferenceMetadata is already a JSON serializable value.
return ['I', id, clientReferenceMetadata];
}
export function scheduleWork(callback: () => void) {

View File

@ -10,12 +10,12 @@
import JSResourceReferenceImpl from 'JSResourceReferenceImpl';
const ReactFlightDOMRelayClientIntegration = {
resolveClientReference(moduleData) {
return new JSResourceReferenceImpl(moduleData);
resolveClientReference(metadata) {
return new JSResourceReferenceImpl(metadata);
},
preloadModule(moduleReference) {},
requireModule(moduleReference) {
return moduleReference._moduleId;
preloadModule(clientReference) {},
requireModule(clientReference) {
return clientReference._moduleId;
},
};

View File

@ -12,7 +12,7 @@ const ReactFlightDOMRelayServerIntegration = {
destination.push(json);
},
close(destination) {},
resolveModuleMetaData(config, resource) {
resolveClientReferenceMetadata(config, resource) {
return resource._moduleId;
},
};

View File

@ -15,13 +15,13 @@ import type {
export type WebpackSSRMap = {
[clientId: string]: {
[clientExportName: string]: ModuleMetaData,
[clientExportName: string]: ClientReferenceMetadata,
},
};
export type BundlerConfig = null | WebpackSSRMap;
export opaque type ModuleMetaData = {
export opaque type ClientReferenceMetadata = {
id: string,
chunks: Array<string>,
name: string,
@ -29,15 +29,15 @@ export opaque type ModuleMetaData = {
};
// eslint-disable-next-line no-unused-vars
export opaque type ClientReference<T> = ModuleMetaData;
export opaque type ClientReference<T> = ClientReferenceMetadata;
export function resolveClientReference<T>(
bundlerConfig: BundlerConfig,
moduleData: ModuleMetaData,
metadata: ClientReferenceMetadata,
): ClientReference<T> {
if (bundlerConfig) {
const resolvedModuleData = bundlerConfig[moduleData.id][moduleData.name];
if (moduleData.async) {
const resolvedModuleData = bundlerConfig[metadata.id][metadata.name];
if (metadata.async) {
return {
id: resolvedModuleData.id,
chunks: resolvedModuleData.chunks,
@ -48,7 +48,7 @@ export function resolveClientReference<T>(
return resolvedModuleData;
}
}
return moduleData;
return metadata;
}
// The chunk cache contains all the chunks we've preloaded so far.
@ -64,9 +64,9 @@ function ignoreReject() {
// Start preloading the modules since we might need them soon.
// This function doesn't suspend.
export function preloadModule<T>(
moduleData: ClientReference<T>,
metadata: ClientReference<T>,
): null | Thenable<any> {
const chunks = moduleData.chunks;
const chunks = metadata.chunks;
const promises = [];
for (let i = 0; i < chunks.length; i++) {
const chunkId = chunks[i];
@ -82,8 +82,8 @@ export function preloadModule<T>(
promises.push(entry);
}
}
if (moduleData.async) {
const existingPromise = asyncModuleCache.get(moduleData.id);
if (metadata.async) {
const existingPromise = asyncModuleCache.get(metadata.id);
if (existingPromise) {
if (existingPromise.status === 'fulfilled') {
return null;
@ -91,7 +91,7 @@ export function preloadModule<T>(
return existingPromise;
} else {
const modulePromise: Thenable<T> = Promise.all(promises).then(() => {
return __webpack_require__(moduleData.id);
return __webpack_require__(metadata.id);
});
modulePromise.then(
value => {
@ -107,7 +107,7 @@ export function preloadModule<T>(
rejectedThenable.reason = reason;
},
);
asyncModuleCache.set(moduleData.id, modulePromise);
asyncModuleCache.set(metadata.id, modulePromise);
return modulePromise;
}
} else if (promises.length > 0) {
@ -119,29 +119,29 @@ export function preloadModule<T>(
// Actually require the module or suspend if it's not yet ready.
// Increase priority if necessary.
export function requireModule<T>(moduleData: ClientReference<T>): T {
export function requireModule<T>(metadata: ClientReference<T>): T {
let moduleExports;
if (moduleData.async) {
if (metadata.async) {
// We assume that preloadModule has been called before, which
// should have added something to the module cache.
const promise: any = asyncModuleCache.get(moduleData.id);
const promise: any = asyncModuleCache.get(metadata.id);
if (promise.status === 'fulfilled') {
moduleExports = promise.value;
} else {
throw promise.reason;
}
} else {
moduleExports = __webpack_require__(moduleData.id);
moduleExports = __webpack_require__(metadata.id);
}
if (moduleData.name === '*') {
if (metadata.name === '*') {
// This is a placeholder value that represents that the caller imported this
// as a CommonJS module as is.
return moduleExports;
}
if (moduleData.name === '') {
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.__esModule ? moduleExports.default : moduleExports;
}
return moduleExports[moduleData.name];
return moduleExports[metadata.name];
}

View File

@ -22,8 +22,14 @@ import {
close,
} from 'react-client/src/ReactFlightClientStream';
type CallServerCallback = <A, T>(
{filepath: string, name: string},
args: A,
) => Promise<T>;
export type Options = {
moduleMap?: BundlerConfig,
callServer?: CallServerCallback,
};
function startReadingFromStream(
@ -59,6 +65,7 @@ function createFromReadableStream<T>(
): Thenable<T> {
const response: FlightResponse = createResponse(
options && options.moduleMap ? options.moduleMap : null,
options && options.callServer ? options.callServer : undefined,
);
startReadingFromStream(response, stream);
return getRoot(response);
@ -70,6 +77,7 @@ function createFromFetch<T>(
): Thenable<T> {
const response: FlightResponse = createResponse(
options && options.moduleMap ? options.moduleMap : null,
options && options.callServer ? options.callServer : undefined,
);
promiseForResponse.then(
function (r) {
@ -88,6 +96,7 @@ function createFromXHR<T>(
): Thenable<T> {
const response: FlightResponse = createResponse(
options && options.moduleMap ? options.moduleMap : null,
options && options.callServer ? options.callServer : undefined,
);
let processedLength = 0;
function progress(e: ProgressEvent): void {

View File

@ -7,14 +7,29 @@
* @flow
*/
import type {ReactModel} from 'react-server/src/ReactFlightServer';
type WebpackMap = {
[filepath: string]: {
[name: string]: ModuleMetaData,
[name: string]: ClientReferenceMetadata,
},
};
export type BundlerConfig = WebpackMap;
export type ServerReference<T: Function> = T & {
$$typeof: symbol,
$$filepath: string,
$$name: string,
$$bound: Array<ReactModel>,
};
export type ServerReferenceMetadata = {
id: string,
name: string,
bound: Promise<Array<ReactModel>>,
};
// eslint-disable-next-line no-unused-vars
export type ClientReference<T> = {
$$typeof: symbol,
@ -23,7 +38,7 @@ export type ClientReference<T> = {
async: boolean,
};
export type ModuleMetaData = {
export type ClientReferenceMetadata = {
id: string,
chunks: Array<string>,
name: string,
@ -33,6 +48,7 @@ export type ModuleMetaData = {
export type ClientReferenceKey = string;
const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference');
export function getClientReferenceKey(
reference: ClientReference<any>,
@ -49,10 +65,14 @@ export function isClientReference(reference: Object): boolean {
return reference.$$typeof === CLIENT_REFERENCE_TAG;
}
export function resolveModuleMetaData<T>(
export function isServerReference(reference: Object): boolean {
return reference.$$typeof === SERVER_REFERENCE_TAG;
}
export function resolveClientReferenceMetadata<T>(
config: BundlerConfig,
clientReference: ClientReference<T>,
): ModuleMetaData {
): ClientReferenceMetadata {
const resolvedModuleData =
config[clientReference.filepath][clientReference.name];
if (clientReference.async) {
@ -66,3 +86,14 @@ export function resolveModuleMetaData<T>(
return resolvedModuleData;
}
}
export function resolveServerReferenceMetadata<T>(
config: BundlerConfig,
serverReference: ServerReference<T>,
): ServerReferenceMetadata {
return {
id: serverReference.$$filepath,
name: serverReference.$$name,
bound: Promise.resolve(serverReference.$$bound),
};
}

View File

@ -95,6 +95,106 @@ export async function getSource(
return defaultGetSource(url, context, defaultGetSource);
}
function addLocalExportedNames(names: Map<string, string>, node: any) {
switch (node.type) {
case 'Identifier':
names.set(node.name, node.name);
return;
case 'ObjectPattern':
for (let i = 0; i < node.properties.length; i++)
addLocalExportedNames(names, node.properties[i]);
return;
case 'ArrayPattern':
for (let i = 0; i < node.elements.length; i++) {
const element = node.elements[i];
if (element) addLocalExportedNames(names, element);
}
return;
case 'Property':
addLocalExportedNames(names, node.value);
return;
case 'AssignmentPattern':
addLocalExportedNames(names, node.left);
return;
case 'RestElement':
addLocalExportedNames(names, node.argument);
return;
case 'ParenthesizedExpression':
addLocalExportedNames(names, node.expression);
return;
}
}
function transformServerModule(
source: string,
body: any,
url: string,
loader: LoadFunction,
): string {
// If the same local name is exported more than once, we only need one of the names.
const localNames: Map<string, string> = new Map();
const localTypes: Map<string, string> = new Map();
for (let i = 0; i < body.length; i++) {
const node = body[i];
switch (node.type) {
case 'ExportAllDeclaration':
// If export * is used, the other file needs to explicitly opt into "use server" too.
break;
case 'ExportDefaultDeclaration':
if (node.declaration.type === 'Identifier') {
localNames.set(node.declaration.name, 'default');
} else if (node.declaration.type === 'FunctionDeclaration') {
if (node.declaration.id) {
localNames.set(node.declaration.id.name, 'default');
localTypes.set(node.declaration.id.name, 'function');
} else {
// TODO: This needs to be rewritten inline because it doesn't have a local name.
}
}
continue;
case 'ExportNamedDeclaration':
if (node.declaration) {
if (node.declaration.type === 'VariableDeclaration') {
const declarations = node.declaration.declarations;
for (let j = 0; j < declarations.length; j++) {
addLocalExportedNames(localNames, declarations[j].id);
}
} else {
const name = node.declaration.id.name;
localNames.set(name, name);
if (node.declaration.type === 'FunctionDeclaration') {
localTypes.set(name, 'function');
}
}
}
if (node.specifiers) {
const specifiers = node.specifiers;
for (let j = 0; j < specifiers.length; j++) {
const specifier = specifiers[j];
localNames.set(specifier.local.name, specifier.exported.name);
}
}
continue;
}
}
let newSrc = source + '\n\n;';
localNames.forEach(function (exported, local) {
if (localTypes.get(local) !== 'function') {
// We first check if the export is a function and if so annotate it.
newSrc += 'if (typeof ' + local + ' === "function") ';
}
newSrc += 'Object.defineProperties(' + local + ',{';
newSrc += '$$typeof: {value: Symbol.for("react.server.reference")},';
newSrc += '$$filepath: {value: ' + JSON.stringify(url) + '},';
newSrc += '$$name: { value: ' + JSON.stringify(exported) + '},';
newSrc += '$$bound: { value: [] }';
newSrc += '});\n';
});
return newSrc;
}
function addExportNames(names: Array<string>, node: any) {
switch (node.type) {
case 'Identifier':
@ -199,39 +299,12 @@ async function parseExportNamesInto(
}
async function transformClientModule(
source: string,
body: any,
url: string,
loader: LoadFunction,
): Promise<string> {
const names: Array<string> = [];
// Do a quick check for the exact string. If it doesn't exist, don't
// bother parsing.
if (source.indexOf('use client') === -1) {
return source;
}
const {body} = acorn.parse(source, {
ecmaVersion: '2019',
sourceType: 'module',
});
let useClient = false;
for (let i = 0; i < body.length; i++) {
const node = body[i];
if (node.type !== 'ExpressionStatement' || !node.directive) {
break;
}
if (node.directive === 'use client') {
useClient = true;
break;
}
}
if (!useClient) {
return source;
}
await parseExportNamesInto(body, names, url, loader);
let newSrc =
@ -294,6 +367,57 @@ async function loadClientImport(
return {format: 'module', source: result.source};
}
async function transformModuleIfNeeded(
source: string,
url: string,
loader: LoadFunction,
): Promise<string> {
// Do a quick check for the exact string. If it doesn't exist, don't
// bother parsing.
if (
source.indexOf('use client') === -1 &&
source.indexOf('use server') === -1
) {
return source;
}
const {body} = acorn.parse(source, {
ecmaVersion: '2019',
sourceType: 'module',
});
let useClient = false;
let useServer = false;
for (let i = 0; i < body.length; i++) {
const node = body[i];
if (node.type !== 'ExpressionStatement' || !node.directive) {
break;
}
if (node.directive === 'use client') {
useClient = true;
}
if (node.directive === 'use server') {
useServer = true;
}
}
if (!useClient && !useServer) {
return source;
}
if (useClient && useServer) {
throw new Error(
'Cannot have both "use client" and "use server" directives in the same file.',
);
}
if (useClient) {
return transformClientModule(body, url, loader);
}
return transformServerModule(source, body, url, loader);
}
export async function transformSource(
source: Source,
context: TransformSourceContext,
@ -309,7 +433,7 @@ export async function transformSource(
if (typeof transformedSource !== 'string') {
throw new Error('Expected source to have been transformed to a string.');
}
const newSrc = await transformClientModule(
const newSrc = await transformModuleIfNeeded(
transformedSource,
context.url,
(url: string, ctx: LoadContext, defaultLoad: LoadFunction) => {
@ -331,7 +455,11 @@ export async function load(
if (typeof result.source !== 'string') {
throw new Error('Expected source to have been loaded into a string.');
}
const newSrc = await transformClientModule(result.source, url, defaultLoad);
const newSrc = await transformModuleIfNeeded(
result.source,
url,
defaultLoad,
);
return {format: 'module', source: newSrc};
}
return defaultLoad(url, context, defaultLoad);

View File

@ -15,8 +15,27 @@ const Module = require('module');
module.exports = function register() {
const CLIENT_REFERENCE = Symbol.for('react.client.reference');
const SERVER_REFERENCE = Symbol.for('react.server.reference');
const PROMISE_PROTOTYPE = Promise.prototype;
// Patch bind on the server to ensure that this creates another
// bound server reference with the additional arguments.
const originalBind = Function.prototype.bind;
/*eslint-disable no-extend-native */
Function.prototype.bind = (function bind(this: any, self: any) {
// $FlowFixMe[unsupported-syntax]
const newFn = originalBind.apply(this, arguments);
if (this.$$typeof === SERVER_REFERENCE) {
// $FlowFixMe[method-unbinding]
const args = Array.prototype.slice.call(arguments, 1);
newFn.$$typeof = SERVER_REFERENCE;
newFn.$$filepath = this.$$filepath;
newFn.$$name = this.$$name;
newFn.$$bound = this.$$bound.concat(args);
}
return newFn;
}: any);
const deepProxyHandlers = {
get: function (target: Function, name: string, receiver: Proxy<Function>) {
switch (name) {
@ -216,7 +235,10 @@ module.exports = function register() {
): void {
// Do a quick check for the exact string. If it doesn't exist, don't
// bother parsing.
if (content.indexOf('use client') === -1) {
if (
content.indexOf('use client') === -1 &&
content.indexOf('use server') === -1
) {
return originalCompile.apply(this, arguments);
}
@ -226,6 +248,7 @@ module.exports = function register() {
});
let useClient = false;
let useServer = false;
for (let i = 0; i < body.length; i++) {
const node = body[i];
if (node.type !== 'ExpressionStatement' || !node.directive) {
@ -233,23 +256,68 @@ module.exports = function register() {
}
if (node.directive === 'use client') {
useClient = true;
break;
}
if (node.directive === 'use server') {
useServer = true;
}
}
if (!useClient) {
if (!useClient && !useServer) {
return originalCompile.apply(this, arguments);
}
const moduleId: string = (url.pathToFileURL(filename).href: any);
const clientReference = Object.defineProperties(({}: any), {
// Represents the whole Module object instead of a particular import.
name: {value: '*'},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: moduleId},
async: {value: false},
});
// $FlowFixMe[incompatible-call] found when upgrading Flow
this.exports = new Proxy(clientReference, proxyHandlers);
if (useClient && useServer) {
throw new Error(
'Cannot have both "use client" and "use server" directives in the same file.',
);
}
if (useClient) {
const moduleId: string = (url.pathToFileURL(filename).href: any);
const clientReference = Object.defineProperties(({}: any), {
// Represents the whole Module object instead of a particular import.
name: {value: '*'},
$$typeof: {value: CLIENT_REFERENCE},
filepath: {value: moduleId},
async: {value: false},
});
// $FlowFixMe[incompatible-call] found when upgrading Flow
this.exports = new Proxy(clientReference, proxyHandlers);
}
if (useServer) {
originalCompile.apply(this, arguments);
const moduleId: string = (url.pathToFileURL(filename).href: any);
const exports = this.exports;
// This module is imported server to server, but opts in to exposing functions by
// reference. If there are any functions in the export.
if (typeof exports === 'function') {
// The module exports a function directly,
Object.defineProperties((exports: any), {
// Represents the whole Module object instead of a particular import.
$$typeof: {value: SERVER_REFERENCE},
$$filepath: {value: moduleId},
$$name: {value: '*'},
$$bound: {value: []},
});
} else {
const keys = Object.keys(exports);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = exports[keys[i]];
if (typeof value === 'function') {
Object.defineProperties((value: any), {
$$typeof: {value: SERVER_REFERENCE},
$$filepath: {value: moduleId},
$$name: {value: key},
$$bound: {value: []},
});
}
}
}
}
};
};

View File

@ -16,8 +16,10 @@ global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
let clientExports;
let serverExports;
let webpackMap;
let webpackModules;
let webpackServerMap;
let act;
let React;
let ReactDOMClient;
@ -33,8 +35,10 @@ describe('ReactFlightDOMBrowser', () => {
act = require('jest-react').act;
const WebpackMock = require('./utils/WebpackMock');
clientExports = WebpackMock.clientExports;
serverExports = WebpackMock.serverExports;
webpackMap = WebpackMock.webpackMap;
webpackModules = WebpackMock.webpackModules;
webpackServerMap = WebpackMock.webpackServerMap;
React = require('react');
ReactDOMClient = require('react-dom/client');
ReactDOMServer = require('react-dom/server.browser');
@ -478,10 +482,10 @@ describe('ReactFlightDOMBrowser', () => {
// Instead, we have to provide a translation from the client meta data to the SSR
// meta data.
const ssrMetaData = webpackMap[ClientComponentOnTheServer.filepath]['*'];
const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*'];
const translationMap = {
[clientId]: {
'*': ssrMetaData,
'*': ssrMetadata,
},
};
@ -811,4 +815,101 @@ describe('ReactFlightDOMBrowser', () => {
});
expect(container.innerHTML).toBe('Hi');
});
function requireServerRef(ref) {
const metaData = webpackServerMap[ref.id][ref.name];
const mod = __webpack_require__(metaData.id);
if (metaData.name === '*') {
return mod;
}
return mod[metaData.name];
}
it('can pass a function by reference from server to client', async () => {
let actionProxy;
function Client({action}) {
actionProxy = action;
return 'Click Me';
}
function send(text) {
return text.toUpperCase();
}
const ServerModule = serverExports({
send,
});
const ClientRef = clientExports(Client);
const stream = ReactServerDOMWriter.renderToReadableStream(
<ClientRef action={ServerModule.send} />,
webpackMap,
);
const response = ReactServerDOMReader.createFromReadableStream(stream, {
async callServer(ref, args) {
const fn = requireServerRef(ref);
return fn.apply(null, args);
},
});
function App() {
return use(response);
}
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
expect(container.innerHTML).toBe('Click Me');
expect(typeof actionProxy).toBe('function');
expect(actionProxy).not.toBe(send);
const result = await actionProxy('hi');
expect(result).toBe('HI');
});
it('can bind arguments to a server reference', async () => {
let actionProxy;
function Client({action}) {
actionProxy = action;
return 'Click Me';
}
const greet = serverExports(function greet(a, b, c) {
return a + ' ' + b + c;
});
const ClientRef = clientExports(Client);
const stream = ReactServerDOMWriter.renderToReadableStream(
<ClientRef action={greet.bind(null, 'Hello', 'World')} />,
webpackMap,
);
const response = ReactServerDOMReader.createFromReadableStream(stream, {
async callServer(ref, args) {
const fn = requireServerRef(ref);
return fn.apply(null, args);
},
});
function App() {
return use(response);
}
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App />);
});
expect(container.innerHTML).toBe('Click Me');
expect(typeof actionProxy).toBe('function');
expect(actionProxy).not.toBe(greet);
const result = await actionProxy('!');
expect(result).toBe('Hello World!');
});
});

View File

@ -11,14 +11,16 @@ const url = require('url');
const Module = require('module');
let webpackModuleIdx = 0;
const webpackModules = {};
const webpackServerModules = {};
const webpackClientModules = {};
const webpackErroredModules = {};
const webpackMap = {};
const webpackServerMap = {};
const webpackClientMap = {};
global.__webpack_require__ = function (id) {
if (webpackErroredModules[id]) {
throw webpackErroredModules[id];
}
return webpackModules[id];
return webpackClientModules[id] || webpackServerModules[id];
};
const previousCompile = Module.prototype._compile;
@ -37,14 +39,15 @@ if (previousCompile === nodeCompile) {
Module.prototype._compile = previousCompile;
exports.webpackMap = webpackMap;
exports.webpackModules = webpackModules;
exports.webpackMap = webpackClientMap;
exports.webpackModules = webpackClientModules;
exports.webpackServerMap = webpackServerMap;
exports.clientModuleError = function clientModuleError(moduleError) {
const idx = '' + webpackModuleIdx++;
webpackErroredModules[idx] = moduleError;
const path = url.pathToFileURL(idx).href;
webpackMap[path] = {
webpackClientMap[path] = {
'': {
id: idx,
chunks: [],
@ -63,9 +66,9 @@ exports.clientModuleError = function clientModuleError(moduleError) {
exports.clientExports = function clientExports(moduleExports) {
const idx = '' + webpackModuleIdx++;
webpackModules[idx] = moduleExports;
webpackClientModules[idx] = moduleExports;
const path = url.pathToFileURL(idx).href;
webpackMap[path] = {
webpackClientMap[path] = {
'': {
id: idx,
chunks: [],
@ -81,7 +84,7 @@ exports.clientExports = function clientExports(moduleExports) {
moduleExports.then(
asyncModuleExports => {
for (const name in asyncModuleExports) {
webpackMap[path][name] = {
webpackClientMap[path][name] = {
id: idx,
chunks: [],
name: name,
@ -92,7 +95,7 @@ exports.clientExports = function clientExports(moduleExports) {
);
}
for (const name in moduleExports) {
webpackMap[path][name] = {
webpackClientMap[path][name] = {
id: idx,
chunks: [],
name: name,
@ -102,3 +105,32 @@ exports.clientExports = function clientExports(moduleExports) {
nodeCompile.call(mod, '"use client"', idx);
return mod.exports;
};
// This tests server to server references. There's another case of client to server references.
exports.serverExports = function serverExports(moduleExports) {
const idx = '' + webpackModuleIdx++;
webpackServerModules[idx] = moduleExports;
const path = url.pathToFileURL(idx).href;
webpackServerMap[path] = {
'': {
id: idx,
chunks: [],
name: '',
},
'*': {
id: idx,
chunks: [],
name: '*',
},
};
for (const name in moduleExports) {
webpackServerMap[path][name] = {
id: idx,
chunks: [],
name: name,
};
}
const mod = {exports: moduleExports};
nodeCompile.call(mod, '"use server"', idx);
return mod.exports;
};

View File

@ -11,7 +11,7 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient';
import type {JSResourceReference} from 'JSResourceReference';
import type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration';
import type {ClientReferenceMetadata} from 'ReactFlightNativeRelayClientIntegration';
export type ClientReference<T> = JSResourceReference<T>;
@ -29,7 +29,7 @@ import {resolveClientReference as resolveClientReferenceImpl} from 'ReactFlightN
import isArray from 'shared/isArray';
export type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration';
export type {ClientReferenceMetadata} from 'ReactFlightNativeRelayClientIntegration';
export type BundlerConfig = null;
@ -39,9 +39,9 @@ export type Response = ResponseBase;
export function resolveClientReference<T>(
bundlerConfig: BundlerConfig,
moduleData: ModuleMetaData,
metadata: ClientReferenceMetadata,
): ClientReference<T> {
return resolveClientReferenceImpl(moduleData);
return resolveClientReferenceImpl(metadata);
}
function parseModelRecursively(

View File

@ -7,7 +7,7 @@
* @flow
*/
import type {ModuleMetaData} from 'ReactFlightNativeRelayServerIntegration';
import type {ClientReferenceMetadata} from 'ReactFlightNativeRelayServerIntegration';
export type JSONValue =
| string
@ -19,7 +19,7 @@ export type JSONValue =
export type RowEncoding =
| ['O', number, JSONValue]
| ['I', number, ModuleMetaData]
| ['I', number, ClientReferenceMetadata]
| ['P', number, string]
| ['S', number, string]
| [

View File

@ -15,11 +15,13 @@ import type {JSResourceReference} from 'JSResourceReference';
import JSResourceReferenceImpl from 'JSResourceReferenceImpl';
export type ClientReference<T> = JSResourceReference<T>;
export type ServerReference<T> = T;
export type ServerReferenceMetadata = {};
import type {
Destination,
BundlerConfig,
ModuleMetaData,
ClientReferenceMetadata,
} from 'ReactFlightNativeRelayServerIntegration';
import {resolveModelToJSON} from 'react-server/src/ReactFlightServer';
@ -27,19 +29,23 @@ import {resolveModelToJSON} from 'react-server/src/ReactFlightServer';
import {
emitRow,
close,
resolveModuleMetaData as resolveModuleMetaDataImpl,
resolveClientReferenceMetadata as resolveClientReferenceMetadataImpl,
} from 'ReactFlightNativeRelayServerIntegration';
export type {
Destination,
BundlerConfig,
ModuleMetaData,
ClientReferenceMetadata,
} from 'ReactFlightNativeRelayServerIntegration';
export function isClientReference(reference: Object): boolean {
return reference instanceof JSResourceReferenceImpl;
}
export function isServerReference(reference: Object): boolean {
return false;
}
export type ClientReferenceKey = ClientReference<any>;
export function getClientReferenceKey(
@ -50,11 +56,18 @@ export function getClientReferenceKey(
return reference;
}
export function resolveModuleMetaData<T>(
export function resolveClientReferenceMetadata<T>(
config: BundlerConfig,
resource: ClientReference<T>,
): ModuleMetaData {
return resolveModuleMetaDataImpl(config, resource);
): ClientReferenceMetadata {
return resolveClientReferenceMetadataImpl(config, resource);
}
export function resolveServerReferenceMetadata<T>(
config: BundlerConfig,
resource: ServerReference<T>,
): ServerReferenceMetadata {
throw new Error('Not implemented.');
}
export type Chunk = RowEncoding;
@ -157,13 +170,13 @@ export function processReferenceChunk(
return ['O', id, reference];
}
export function processModuleChunk(
export function processImportChunk(
request: Request,
id: number,
moduleMetaData: ModuleMetaData,
clientReferenceMetadata: ClientReferenceMetadata,
): Chunk {
// The moduleMetaData is already a JSON serializable value.
return ['I', id, moduleMetaData];
// The clientReferenceMetadata is already a JSON serializable value.
return ['I', id, clientReferenceMetadata];
}
export function scheduleWork(callback: () => void) {

View File

@ -10,12 +10,12 @@
import JSResourceReferenceImpl from 'JSResourceReferenceImpl';
const ReactFlightNativeRelayClientIntegration = {
resolveClientReference(moduleData) {
return new JSResourceReferenceImpl(moduleData);
resolveClientReference(metadata) {
return new JSResourceReferenceImpl(metadata);
},
preloadModule(moduleReference) {},
requireModule(moduleReference) {
return moduleReference._moduleId;
preloadModule(clientReference) {},
requireModule(clientReference) {
return clientReference._moduleId;
},
};

View File

@ -12,7 +12,7 @@ const ReactFlightNativeRelayServerIntegration = {
destination.push(json);
},
close(destination) {},
resolveModuleMetaData(config, resource) {
resolveClientReferenceMetadata(config, resource) {
return resource._moduleId;
},
};

View File

@ -11,9 +11,11 @@ import type {
Destination,
Chunk,
BundlerConfig,
ModuleMetaData,
ClientReferenceMetadata,
ClientReference,
ClientReferenceKey,
ServerReference,
ServerReferenceMetadata,
} from './ReactFlightServerConfig';
import type {ContextSnapshot} from './ReactFlightNewContext';
import type {ThenableState} from './ReactFlightThenable';
@ -37,13 +39,15 @@ import {
close,
closeWithError,
processModelChunk,
processModuleChunk,
processImportChunk,
processErrorChunkProd,
processErrorChunkDev,
processReferenceChunk,
resolveModuleMetaData,
resolveClientReferenceMetadata,
resolveServerReferenceMetadata,
getClientReferenceKey,
isClientReference,
isServerReference,
supportsRequestStorage,
requestStorage,
} from './ReactFlightServerConfig';
@ -101,7 +105,8 @@ export type ReactModel =
| symbol
| null
| Iterable<ReactModel>
| ReactModelObject;
| ReactModelObject
| Promise<ReactModel>;
type ReactModelObject = {+[key: string]: ReactModel};
@ -129,11 +134,12 @@ export type Request = {
pendingChunks: number,
abortableTasks: Set<Task>,
pingedTasks: Array<Task>,
completedModuleChunks: Array<Chunk>,
completedImportChunks: Array<Chunk>,
completedJSONChunks: Array<Chunk>,
completedErrorChunks: Array<Chunk>,
writtenSymbols: Map<symbol, number>,
writtenModules: Map<ClientReferenceKey, number>,
writtenClientReferences: Map<ClientReferenceKey, number>,
writtenServerReferences: Map<ServerReference<any>, number>,
writtenProviders: Map<string, number>,
identifierPrefix: string,
identifierCount: number,
@ -182,11 +188,12 @@ export function createRequest(
pendingChunks: 0,
abortableTasks: abortSet,
pingedTasks: pingedTasks,
completedModuleChunks: [],
completedJSONChunks: [],
completedErrorChunks: [],
completedImportChunks: ([]: Array<Chunk>),
completedJSONChunks: ([]: Array<Chunk>),
completedErrorChunks: ([]: Array<Chunk>),
writtenSymbols: new Map(),
writtenModules: new Map(),
writtenClientReferences: new Map(),
writtenServerReferences: new Map(),
writtenProviders: new Map(),
identifierPrefix: identifierPrefix || '',
identifierCount: 1,
@ -509,6 +516,10 @@ function serializePromiseID(id: number): string {
return '$@' + id.toString(16);
}
function serializeServerReferenceID(id: number): string {
return '$F' + id.toString(16);
}
function serializeSymbolReference(name: string): string {
return '$S' + name;
}
@ -521,11 +532,12 @@ function serializeClientReference(
request: Request,
parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray<ReactModel>,
key: string,
moduleReference: ClientReference<any>,
clientReference: ClientReference<any>,
): string {
const moduleKey: ClientReferenceKey = getClientReferenceKey(moduleReference);
const writtenModules = request.writtenModules;
const existingId = writtenModules.get(moduleKey);
const clientReferenceKey: ClientReferenceKey =
getClientReferenceKey(clientReference);
const writtenClientReferences = request.writtenClientReferences;
const existingId = writtenClientReferences.get(clientReferenceKey);
if (existingId !== undefined) {
if (parent[0] === REACT_ELEMENT_TYPE && key === '1') {
// If we're encoding the "type" of an element, we can refer
@ -538,23 +550,21 @@ function serializeClientReference(
return serializeByValueID(existingId);
}
try {
const moduleMetaData: ModuleMetaData = resolveModuleMetaData(
request.bundlerConfig,
moduleReference,
);
const clientReferenceMetadata: ClientReferenceMetadata =
resolveClientReferenceMetadata(request.bundlerConfig, clientReference);
request.pendingChunks++;
const moduleId = request.nextChunkId++;
emitModuleChunk(request, moduleId, moduleMetaData);
writtenModules.set(moduleKey, moduleId);
const importId = request.nextChunkId++;
emitImportChunk(request, importId, clientReferenceMetadata);
writtenClientReferences.set(clientReferenceKey, importId);
if (parent[0] === REACT_ELEMENT_TYPE && key === '1') {
// If we're encoding the "type" of an element, we can refer
// to that by a lazy reference instead of directly since React
// knows how to deal with lazy values. This lets us suspend
// on this component rather than its parent until the code has
// loaded.
return serializeLazyID(moduleId);
return serializeLazyID(importId);
}
return serializeByValueID(moduleId);
return serializeByValueID(importId);
} catch (x) {
request.pendingChunks++;
const errorId = request.nextChunkId++;
@ -569,6 +579,32 @@ function serializeClientReference(
}
}
function serializeServerReference(
request: Request,
parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray<ReactModel>,
key: string,
serverReference: ServerReference<any>,
): string {
const writtenServerReferences = request.writtenServerReferences;
const existingId = writtenServerReferences.get(serverReference);
if (existingId !== undefined) {
return serializeServerReferenceID(existingId);
}
const serverReferenceMetadata: ServerReferenceMetadata =
resolveServerReferenceMetadata(request.bundlerConfig, serverReference);
request.pendingChunks++;
const metadataId = request.nextChunkId++;
// We assume that this object doesn't suspend.
const processedChunk = processModelChunk(
request,
metadataId,
serverReferenceMetadata,
);
request.completedJSONChunks.push(processedChunk);
writtenServerReferences.set(serverReference, metadataId);
return serializeServerReferenceID(metadataId);
}
function escapeStringValue(value: string): string {
if (value[0] === '$') {
// We need to escape $ or @ prefixed strings since we use those to encode
@ -991,6 +1027,7 @@ export function resolveModelToJSON(
if (typeof value === 'object') {
if (isClientReference(value)) {
return serializeClientReference(request, parent, key, (value: any));
// $FlowFixMe[method-unbinding]
} else if (typeof value.then === 'function') {
// We assume that any object with a .then property is a "Thenable" type,
// or a Promise type. Either of which can be represented by a Promise.
@ -1067,6 +1104,9 @@ export function resolveModelToJSON(
if (isClientReference(value)) {
return serializeClientReference(request, parent, key, (value: any));
}
if (isServerReference(value)) {
return serializeServerReference(request, parent, key, (value: any));
}
if (/^on[A-Z]/.test(key)) {
throw new Error(
'Event handlers cannot be passed to Client Component props.' +
@ -1076,7 +1116,7 @@ export function resolveModelToJSON(
} else {
throw new Error(
'Functions cannot be passed directly to Client Components ' +
"because they're not serializable." +
'unless you explicitly expose it by marking it with "use server".' +
describeObjectForErrorMessage(parent, key),
);
}
@ -1203,19 +1243,23 @@ function emitErrorChunkDev(
request.completedErrorChunks.push(processedChunk);
}
function emitModuleChunk(
function emitImportChunk(
request: Request,
id: number,
moduleMetaData: ModuleMetaData,
clientReferenceMetadata: ClientReferenceMetadata,
): void {
const processedChunk = processModuleChunk(request, id, moduleMetaData);
request.completedModuleChunks.push(processedChunk);
const processedChunk = processImportChunk(
request,
id,
clientReferenceMetadata,
);
request.completedImportChunks.push(processedChunk);
}
function emitSymbolChunk(request: Request, id: number, name: string): void {
const symbolReference = serializeSymbolReference(name);
const processedChunk = processReferenceChunk(request, id, symbolReference);
request.completedModuleChunks.push(processedChunk);
request.completedImportChunks.push(processedChunk);
}
function emitProviderChunk(
@ -1367,11 +1411,11 @@ function flushCompletedChunks(
try {
// We emit module chunks first in the stream so that
// they can be preloaded as early as possible.
const moduleChunks = request.completedModuleChunks;
const importsChunks = request.completedImportChunks;
let i = 0;
for (; i < moduleChunks.length; i++) {
for (; i < importsChunks.length; i++) {
request.pendingChunks--;
const chunk = moduleChunks[i];
const chunk = importsChunks[i];
const keepWriting: boolean = writeChunkAndReturn(destination, chunk);
if (!keepWriting) {
request.destination = null;
@ -1379,7 +1423,7 @@ function flushCompletedChunks(
break;
}
}
moduleChunks.splice(0, i);
importsChunks.splice(0, i);
// Next comes model data.
const jsonChunks = request.completedJSONChunks;
i = 0;

View File

@ -11,8 +11,14 @@ declare var $$$hostConfig: any;
export opaque type BundlerConfig = mixed;
export opaque type ClientReference<T> = mixed; // eslint-disable-line no-unused-vars
export opaque type ModuleMetaData: any = mixed;
export opaque type ServerReference<T> = mixed; // eslint-disable-line no-unused-vars
export opaque type ClientReferenceMetadata: any = mixed;
export opaque type ServerReferenceMetadata: any = mixed;
export opaque type ClientReferenceKey: any = mixed;
export const isClientReference = $$$hostConfig.isClientReference;
export const isServerReference = $$$hostConfig.isServerReference;
export const getClientReferenceKey = $$$hostConfig.getClientReferenceKey;
export const resolveModuleMetaData = $$$hostConfig.resolveModuleMetaData;
export const resolveClientReferenceMetadata =
$$$hostConfig.resolveClientReferenceMetadata;
export const resolveServerReferenceMetadata =
$$$hostConfig.resolveServerReferenceMetadata;

View File

@ -141,12 +141,12 @@ export function processReferenceChunk(
return stringToChunk(row);
}
export function processModuleChunk(
export function processImportChunk(
request: Request,
id: number,
moduleMetaData: ReactModel,
clientReferenceMetadata: ReactModel,
): Chunk {
const json: string = stringify(moduleMetaData);
const json: string = stringify(clientReferenceMetadata);
const row = serializeRowHeader('I', id) + json + '\n';
return stringToChunk(row);
}

View File

@ -361,7 +361,7 @@
"372": "Cannot call unstable_createEventHandle with \"%s\", as it is not an event known to React.",
"373": "This Hook is not supported in Server Components.",
"374": "Event handlers cannot be passed to Client Component props.%s\nIf you need interactivity, consider converting part of this to a Client Component.",
"375": "Functions cannot be passed directly to Client Components because they're not serializable.%s",
"375": "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with \"use server\".%s",
"376": "Only global symbols received from Symbol.for(...) can be passed to Client Components. The symbol Symbol.for(%s) cannot be found among global symbols.%s",
"377": "BigInt (%s) is not yet supported in Client Component props.%s",
"378": "Type %s is not supported in Client Component props.%s",
@ -450,5 +450,6 @@
"462": "Unexpected SuspendedReason. This is a bug in React.",
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
"464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
"465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle."
}
"465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.",
"466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime."
}

View File

@ -46,19 +46,19 @@ declare module 'ReactFlightDOMRelayServerIntegration' {
): void;
declare export function close(destination: Destination): void;
declare export type ModuleMetaData = JSONValue;
declare export function resolveModuleMetaData<T>(
declare export type ClientReferenceMetadata = JSONValue;
declare export function resolveClientReferenceMetadata<T>(
config: BundlerConfig,
resourceReference: JSResourceReference<T>,
): ModuleMetaData;
): ClientReferenceMetadata;
}
declare module 'ReactFlightDOMRelayClientIntegration' {
import type {JSResourceReference} from 'JSResourceReference';
declare export opaque type ModuleMetaData;
declare export opaque type ClientReferenceMetadata;
declare export function resolveClientReference<T>(
moduleData: ModuleMetaData,
moduleData: ClientReferenceMetadata,
): JSResourceReference<T>;
declare export function preloadModule<T>(
moduleReference: JSResourceReference<T>,
@ -79,19 +79,19 @@ declare module 'ReactFlightNativeRelayServerIntegration' {
): void;
declare export function close(destination: Destination): void;
declare export type ModuleMetaData = JSONValue;
declare export function resolveModuleMetaData<T>(
declare export type ClientReferenceMetadata = JSONValue;
declare export function resolveClientReferenceMetadata<T>(
config: BundlerConfig,
resourceReference: JSResourceReference<T>,
): ModuleMetaData;
): ClientReferenceMetadata;
}
declare module 'ReactFlightNativeRelayClientIntegration' {
import type {JSResourceReference} from 'JSResourceReference';
declare export opaque type ModuleMetaData;
declare export opaque type ClientReferenceMetadata;
declare export function resolveClientReference<T>(
moduleData: ModuleMetaData,
moduleData: ClientReferenceMetadata,
): JSResourceReference<T>;
declare export function preloadModule<T>(
moduleReference: JSResourceReference<T>,

View File

@ -83,8 +83,9 @@ jest.mock('react-server/flight', () => {
jest.mock(shimServerFormatConfigPath, () => config);
jest.mock('react-server/src/ReactFlightServerBundlerConfigCustom', () => ({
isClientReference: config.isClientReference,
isServerReference: config.isServerReference,
getClientReferenceKey: config.getClientReferenceKey,
resolveModuleMetaData: config.resolveModuleMetaData,
resolveClientReferenceMetadata: config.resolveClientReferenceMetadata,
}));
jest.mock(shimFlightServerConfigPath, () =>
jest.requireActual(

View File

@ -225,6 +225,7 @@ function isProductionBundleType(bundleType) {
switch (bundleType) {
case NODE_ES2015:
case NODE_ESM:
return true;
case UMD_DEV:
case NODE_DEV:
case BUN_DEV:
@ -377,12 +378,18 @@ function getPlugins(
// Please don't enable this for anything else!
isUMDBundle && entry === 'react-art' && commonjs(),
// Apply dead code elimination and/or minification.
// closure doesn't yet support leaving ESM imports intact
isProduction &&
bundleType !== NODE_ESM &&
closure({
compilation_level: 'SIMPLE',
language_in: 'ECMASCRIPT_2015',
language_in: 'ECMASCRIPT_2018',
language_out:
bundleType === BROWSER_SCRIPT ? 'ECMASCRIPT5' : 'ECMASCRIPT5_STRICT',
bundleType === NODE_ES2015
? 'ECMASCRIPT_2018'
: bundleType === BROWSER_SCRIPT
? 'ECMASCRIPT5'
: 'ECMASCRIPT5_STRICT',
env: 'CUSTOM',
warning_level: 'QUIET',
apply_input_source_maps: false,

View File

@ -417,7 +417,7 @@ const bundles = [
bundleTypes: [FB_WWW_DEV, FB_WWW_PROD],
moduleType: RENDERER,
entry: 'react-server-dom-relay/server',
global: 'ReactFlightDOMRelayServer', // TODO: Rename to Writer
global: 'ReactFlightDOMRelayServer',
minifyWithProdErrorCodes: false,
wrapWithModuleBoundaries: false,
externals: [
@ -465,7 +465,7 @@ const bundles = [
bundleTypes: [RN_FB_DEV, RN_FB_PROD],
moduleType: RENDERER,
entry: 'react-server-native-relay',
global: 'ReactFlightNativeRelayClient', // TODO: Rename to Reader
global: 'ReactFlightNativeRelayClient',
minifyWithProdErrorCodes: true,
wrapWithModuleBoundaries: false,
externals: [