Skip to content

Commit

Permalink
Enable passing Server References from Server to Client (#26124)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored Feb 10, 2023
1 parent 6c75d4e commit ef9f6e7
Show file tree
Hide file tree
Showing 38 changed files with 844 additions and 219 deletions.
1 change: 1 addition & 0 deletions fixtures/flight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions fixtures/flight/server/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,25 @@ babelRegister({
});

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

// Application
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([
Expand Down
64 changes: 41 additions & 23 deletions fixtures/flight/server/handler.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
};
6 changes: 6 additions & 0 deletions fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -23,6 +26,9 @@ export default async function App() {
<ShowMore>
<p>Lorem ipsum</p>
</ShowMore>
<div>
<Button action={like}>Like</Button>
</div>
</Container>
);
}
15 changes: 15 additions & 0 deletions fixtures/flight/src/Button.js
Original file line number Diff line number Diff line change
@@ -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>
);
}
6 changes: 6 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use server';

export async function like() {
console.log('Like');
return 'Liked';
}
17 changes: 16 additions & 1 deletion fixtures/flight/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions fixtures/flight/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
79 changes: 70 additions & 9 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {LazyComponent} from 'react/src/ReactLazy';

import type {
ClientReference,
ModuleMetaData,
ClientReferenceMetadata,
UninitializedModel,
Response,
BundlerConfig,
Expand All @@ -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
Expand Down Expand Up @@ -148,6 +150,7 @@ Chunk.prototype.then = function <T>(

export type ResponseBase = {
_bundlerConfig: BundlerConfig,
_callServer: CallServerCallback,
_chunks: Map<number, SomeChunk<any>>,
...
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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);
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions packages/react-client/src/ReactFlightClientStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* @flow
*/

import type {CallServerCallback} from './ReactFlightClient';
import type {Response} from './ReactFlightClientHostConfigStream';

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

import {
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit ef9f6e7

Please sign in to comment.