Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Flight Reply] Add Reply Encoding #26360

Merged
merged 3 commits into from
Mar 10, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fixtures/flight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"babel-preset-react-app": "^10.0.1",
"body-parser": "^1.20.1",
"browserslist": "^4.18.1",
"busboy": "^1.6.0",
"camelcase": "^6.2.1",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"compression": "^1.7.4",
Expand Down
19 changes: 14 additions & 5 deletions fixtures/flight/server/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ if (typeof fetch === 'undefined') {

const express = require('express');
const bodyParser = require('body-parser');
const busboy = require('busboy');
const app = express();
const compress = require('compression');

Expand Down Expand Up @@ -95,9 +96,8 @@ app.get('/', async function (req, res) {
});

app.post('/', bodyParser.text(), async function (req, res) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} =
await import('react-server-dom-webpack/server');
const serverReference = req.get('rsc-action');
const [filepath, name] = serverReference.split('#');
const action = (await import(filepath))[name];
Expand All @@ -108,9 +108,18 @@ app.post('/', bodyParser.text(), async function (req, res) {
throw new Error('Invalid action');
}

const args = JSON.parse(req.body);
const result = action.apply(null, args);
let args;
if (req.is('multipart/form-data')) {
// Use busboy to streamingly parse the reply from form-data.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Server Components: Streamingly Fast.

Streaming means good.

const bb = busboy({headers: req.headers});
const reply = decodeReplyFromBusboy(bb);
req.pipe(bb);
args = await reply;
} else {
args = await decodeReply(req.body);
}

const result = action.apply(null, args);
const {pipe} = renderToPipeableStream(result, {});
pipe(res);
});
Expand Down
10 changes: 1 addition & 9 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
'use server';

export async function like() {
return new Promise((resolve, reject) =>
setTimeout(
() =>
Math.random() > 0.5
? resolve('Liked')
: reject(new Error('Failed to like')),
500
)
);
return new Promise((resolve, reject) => resolve('Liked'));
}
10 changes: 5 additions & 5 deletions fixtures/flight/src/index.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import * as React from 'react';
import {Suspense} from 'react';
import ReactDOM from 'react-dom/client';
import ReactServerDOMReader from 'react-server-dom-webpack/client';
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client';

// TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet.
import './style.css';

let data = ReactServerDOMReader.createFromFetch(
let data = createFromFetch(
fetch('/', {
headers: {
Accept: 'text/x-component',
},
}),
{
callServer(id, args) {
async callServer(id, args) {
const response = fetch('/', {
method: 'POST',
headers: {
Accept: 'text/x-component',
'rsc-action': id,
},
body: JSON.stringify(args),
body: await encodeReply(args),
});
return ReactServerDOMReader.createFromFetch(response);
return createFromFetch(response);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you model the resolve handling like this is a RSC render? I get that you may want to return a rendered tree but if this is just a "API" endpoint that returns JSON data or something it seems odd to process the response like it is a component tree. It's interesting that if you did render suspense boundaries you could get an object that could construct itself incrementally as bits stream in but I have a hard time wrapping my head around that as a general purpose response handler instead of something specifc to fetching and integrating flight payloads

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not just a renderer. It's a general purpose serializer with much more rich options of the payload, and more to come. So it's used as a better alternative to JSON.stringify.

Since that implementation also happens to be what you'd use to render a real response which likely contains an update to the current page, it's conveniently the same thing.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right but if I send { eager: "eager", lazy: sleep(5000).then(() => "lazy") }

i still have to wait 5 seconds to see the result because there is no way to expose an incomplete value and then update it to a complete value. We use Suspense boundaries for React trees and we can re-evaluate the value on each render attempt, but for a general purpose response object we'd need some similar kind of construct which groups a value into revealable. I mean we could literally use suspense but that just feels kind strange

Do you have a vision for what this more generalizable type is so you can really take advantage of the unique characteristics of the flight format and runtime?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're just describing Promises? The consumer can just const value = await result.lazy?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not really anything fancy. It's just passing values that are already common place in the language so you just use it as if it was local.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this basically allows us to stream JSON back to the client by serialising some properties as promises, which is pretty cool, I must say. 🎉 Although this is already working in main and has nothing to do with this change, but I'm just now realising this. So this example would be serialised to

0:"$@1"
1:{"eager":"eager","lazy":"$@2"}
2:"lazy"

where the eager chunk is sent immediately, and the lazy chunk is sent after 5 seconds. With async iterators soon to be serialisable, I guess you could use that e.g. to render the progress of a long running task?

},
}
);
Expand Down
3 changes: 3 additions & 0 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
parseModel,
} from './ReactFlightClientHostConfig';

import {knownServerReferences} from './ReactFlightServerReferenceRegistry';

import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';

import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
Expand Down Expand Up @@ -495,6 +497,7 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
return callServer(metaData.id, bound.concat(args));
});
};
knownServerReferences.set(proxy, metaData);
return proxy;
}

Expand Down
278 changes: 278 additions & 0 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* 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';

import {knownServerReferences} from './ReactFlightServerReferenceRegistry';

import {
REACT_ELEMENT_TYPE,
REACT_LAZY_TYPE,
REACT_PROVIDER_TYPE,
} from 'shared/ReactSymbols';

import {
describeObjectForErrorMessage,
isSimpleObject,
objectName,
} from 'shared/ReactSerializationErrors';

import isArray from 'shared/isArray';

type ReactJSONValue =
| string
| boolean
| number
| null
| $ReadOnlyArray<ReactJSONValue>
| ReactServerObject;

export opaque type ServerReference<T> = T;

// Serializable values
export type ReactServerValue =
// References are passed by their value
| ServerReference<any>
// The rest are passed as is. Sub-types can be passed in but lose their
// subtype, so the receiver can only accept once of these.
| string
| boolean
| number
| symbol
| null
| Iterable<ReactServerValue>
| Array<ReactServerValue>
| ReactServerObject
| Promise<ReactServerValue>; // Thenable<ReactServerValue>

type ReactServerObject = {+[key: string]: ReactServerValue};

// function serializeByValueID(id: number): string {
// return '$' + id.toString(16);
// }

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;
}

function escapeStringValue(value: string): string {
if (value[0] === '$') {
// We need to escape $ prefixed strings since we use those to encode
// references to IDs and as special symbol values.
return '$' + value;
} else {
return value;
}
}

export function processReply(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling with the framing of reply. It requires a context that I'm not sure everyone is going to have initially. Reply to what exactly? In trying to make it make sense to me I think of it as "The server sent some data to me and since I am sending data in reverse I am replying". But that doesn't really make a lot of sense.

If we always position our names from the perspective of the client then we make a Request (client -> server) and get a Response (server -> client). If we call the analog of Request Reply then we have Reply and Response which just seems odd.

I assume that calling it Reply is because Flight goes in a certain direction today and so to make a reverse flight protocol using reply seems kinda natural. But that's sort of a temporal detail (one existed before the other).

If we developed both at the same time I think we might come up with

Flight Response : server -> client
Flight Request : client -> server

But Flight Request may hew too closely to a regular request which is not a Flight Request so maybe we call it Flight Call since it models calling functions on the server?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client can only reply to something that the server already sent. It's still server centric. E.g. you can't call a server function first. It's also locked in version so even if you've received a function in an earlier visit, it might not be valid anymore. You must get a new handle to something that you can reply to. Similarly the Server can pass any Client References it wants. The Client can just reply with what it has already been given and prompted with.

It's not always a call to functions on the server. E.g. updates/navigations that serializes Server Context or params also contain a "reply".

Request/Response as terminology doesn't make sense since it's overloaded with the HTTP request/response that happens in both directions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Postback a similar concept?

Copy link
Collaborator Author

@sebmarkbage sebmarkbage Mar 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea but we're also going to have literal postbacks which is not this, so it'd be overloaded.

root: ReactServerValue,
resolve: (string | FormData) => void,
reject: (error: mixed) => void,
): void {
let nextPartId = 1;
let pendingParts = 0;
let formData: null | FormData = null;

function resolveToJSON(
this:
| {+[key: string | number]: ReactServerValue}
| $ReadOnlyArray<ReactServerValue>,
key: string,
value: ReactServerValue,
): ReactJSONValue {
const parent = this;
if (__DEV__) {
// $FlowFixMe
const originalValue = this[key];
if (typeof originalValue === 'object' && originalValue !== value) {
if (objectName(originalValue) !== 'Object') {
console.error(
'Only plain objects can be passed to Server Functions from the Client. ' +
'%s objects are not supported.%s',
objectName(originalValue),
describeObjectForErrorMessage(parent, key),
);
} else {
console.error(
'Only plain objects can be passed to Server Functions from the Client. ' +
'Objects with toJSON methods are not supported. Convert it manually ' +
'to a simple value before passing it to props.%s',
describeObjectForErrorMessage(parent, key),
);
}
}
}

if (value === null) {
return null;
}

if (typeof value === 'object') {
// $FlowFixMe[method-unbinding]
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.
if (formData === null) {
// Upgrade to use FormData to allow us to stream this value.
formData = new FormData();
}
pendingParts++;
const promiseId = nextPartId++;
const thenable: Thenable<any> = (value: any);
thenable.then(
partValue => {
const partJSON = JSON.stringify(partValue, resolveToJSON);
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
const data: FormData = formData;
// eslint-disable-next-line react-internal/safe-string-coercion
data.append('' + promiseId, partJSON);
pendingParts--;
if (pendingParts === 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going to trust the thenables to not be evil and call resolve multiple times? This came up with Suspensey CSS/Images too but we can punt there because we control the implementation. In this case the thenables are from userspace.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I think it's fair to assume that. I don't think this be used much without real promises anyway. Mainly the ones we vendor which are also real-ish.

This is also the implementation on the server already.

resolve(data);
}
},
reason => {
// In the future we could consider serializing this as an error
// that throws on the server instead.
reject(reason);
},
);
return serializePromiseID(promiseId);
}

if (__DEV__) {
if (value !== null && !isArray(value)) {
// Verify that this is a simple plain object.
if ((value: any).$$typeof === REACT_ELEMENT_TYPE) {
console.error(
'React Element cannot be passed to Server Functions from the Client.%s',
describeObjectForErrorMessage(parent, key),
);
} else if ((value: any).$$typeof === REACT_LAZY_TYPE) {
console.error(
'React Lazy cannot be passed to Server Functions from the Client.%s',
describeObjectForErrorMessage(parent, key),
);
} else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) {
console.error(
'React Context Providers cannot be passed to Server Functions from the Client.%s',
describeObjectForErrorMessage(parent, key),
);
} else if (objectName(value) !== 'Object') {
console.error(
'Only plain objects can be passed to Client Components from Server Components. ' +
'%s objects are not supported.%s',
objectName(value),
describeObjectForErrorMessage(parent, key),
);
} else if (!isSimpleObject(value)) {
console.error(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Classes or other objects with methods are not supported.%s',
describeObjectForErrorMessage(parent, key),
);
} else if (Object.getOwnPropertySymbols) {
const symbols = Object.getOwnPropertySymbols(value);
if (symbols.length > 0) {
console.error(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like %s are not supported.%s',
symbols[0].description,
describeObjectForErrorMessage(parent, key),
);
}
}
}
}

// $FlowFixMe
return value;
}

if (typeof value === 'string') {
return escapeStringValue(value);
}

if (
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'undefined'
) {
return value;
}

if (typeof value === 'function') {
const metaData = knownServerReferences.get(value);
if (metaData !== undefined) {
const metaDataJSON = JSON.stringify(metaData, resolveToJSON);
if (formData === null) {
// Upgrade to use FormData to allow us to stream this value.
formData = new FormData();
}
// The reference to this function came from the same client so we can pass it back.
const refId = nextPartId++;
// eslint-disable-next-line react-internal/safe-string-coercion
formData.set('' + refId, metaDataJSON);
return serializeServerReferenceID(refId);
}
throw new Error(
'Client Functions cannot be passed directly to Server Functions. ' +
'Only Functions passed from the Server can be passed back again.',
);
}

if (typeof value === 'symbol') {
// $FlowFixMe `description` might be undefined
const name: string = value.description;
if (Symbol.for(name) !== value) {
throw new Error(
'Only global symbols received from Symbol.for(...) can be passed to Server Functions. ' +
`The symbol Symbol.for(${
// $FlowFixMe `description` might be undefined
value.description
}) cannot be found among global symbols.`,
);
}
return serializeSymbolReference(name);
}

if (typeof value === 'bigint') {
throw new Error(
`BigInt (${value}) is not yet supported as an argument to a Server Function.`,
);
}

throw new Error(
`Type ${typeof value} is not supported as an argument to a Server Function.`,
);
}

// $FlowFixMe[incompatible-type] it's not going to be undefined because we'll encode it.
const json: string = JSON.stringify(root, resolveToJSON);
if (formData === null) {
// If it's a simple data structure, we just use plain JSON.
resolve(json);
} else {
// Otherwise, we use FormData to let us stream in the result.
formData.set('0', json);
if (pendingParts === 0) {
// $FlowFixMe[incompatible-call] this has already been refined.
resolve(formData);
}
}
}
Loading