Skip to content

Commit ef9f6e7

Browse files
authored
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.
1 parent 6c75d4e commit ef9f6e7

38 files changed

+844
-219
lines changed

fixtures/flight/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"babel-plugin-named-asset-import": "^0.3.8",
1717
"babel-preset-react-app": "^10.0.1",
1818
"bfj": "^7.0.2",
19+
"body-parser": "^1.20.1",
1920
"browserslist": "^4.18.1",
2021
"camelcase": "^6.2.1",
2122
"case-sensitive-paths-webpack-plugin": "^2.4.0",

fixtures/flight/server/cli.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,25 @@ babelRegister({
2525
});
2626

2727
const express = require('express');
28+
const bodyParser = require('body-parser');
2829
const app = express();
2930

3031
// Application
3132
app.get('/', function (req, res) {
3233
require('./handler.js')(req, res);
3334
});
3435

36+
app.options('/', function (req, res) {
37+
res.setHeader('Allow', 'Allow: GET,HEAD,POST');
38+
res.setHeader('Access-Control-Allow-Origin', '*');
39+
res.setHeader('Access-Control-Allow-Headers', 'rsc-action');
40+
res.end();
41+
});
42+
43+
app.post('/', bodyParser.text(), function (req, res) {
44+
require('./handler.js')(req, res);
45+
});
46+
3547
app.get('/todos', function (req, res) {
3648
res.setHeader('Access-Control-Allow-Origin', '*');
3749
res.json([

fixtures/flight/server/handler.js

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,49 @@
11
'use strict';
22

33
const {renderToPipeableStream} = require('react-server-dom-webpack/server');
4-
const {readFile} = require('fs');
4+
const {readFile} = require('fs').promises;
55
const {resolve} = require('path');
66
const React = require('react');
77

8-
module.exports = function (req, res) {
9-
// const m = require('../src/App.js');
10-
import('../src/App.js').then(m => {
11-
const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build';
12-
readFile(
13-
resolve(__dirname, `../${dist}/react-client-manifest.json`),
14-
'utf8',
15-
(err, data) => {
16-
if (err) {
17-
throw err;
18-
}
19-
20-
const App = m.default.default || m.default;
21-
res.setHeader('Access-Control-Allow-Origin', '*');
22-
const moduleMap = JSON.parse(data);
23-
const {pipe} = renderToPipeableStream(
24-
React.createElement(App),
25-
moduleMap
26-
);
27-
pipe(res);
8+
module.exports = async function (req, res) {
9+
switch (req.method) {
10+
case 'POST': {
11+
const serverReference = JSON.parse(req.get('rsc-action'));
12+
const {filepath, name} = serverReference;
13+
const action = (await import(filepath))[name];
14+
// Validate that this is actually a function we intended to expose and
15+
// not the client trying to invoke arbitrary functions. In a real app,
16+
// you'd have a manifest verifying this before even importing it.
17+
if (action.$$typeof !== Symbol.for('react.server.reference')) {
18+
throw new Error('Invalid action');
2819
}
29-
);
30-
});
20+
21+
const args = JSON.parse(req.body);
22+
const result = action.apply(null, args);
23+
24+
res.setHeader('Access-Control-Allow-Origin', '*');
25+
const {pipe} = renderToPipeableStream(result, {});
26+
pipe(res);
27+
28+
return;
29+
}
30+
default: {
31+
// const m = require('../src/App.js');
32+
const m = await import('../src/App.js');
33+
const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build';
34+
const data = await readFile(
35+
resolve(__dirname, `../${dist}/react-client-manifest.json`),
36+
'utf8'
37+
);
38+
const App = m.default.default || m.default;
39+
res.setHeader('Access-Control-Allow-Origin', '*');
40+
const moduleMap = JSON.parse(data);
41+
const {pipe} = renderToPipeableStream(
42+
React.createElement(App),
43+
moduleMap
44+
);
45+
pipe(res);
46+
return;
47+
}
48+
}
3149
};

fixtures/flight/src/App.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {Counter} from './Counter.js';
66
import {Counter as Counter2} from './Counter2.js';
77

88
import ShowMore from './ShowMore.js';
9+
import Button from './Button.js';
10+
11+
import {like} from './actions.js';
912

1013
export default async function App() {
1114
const res = await fetch('http://localhost:3001/todos');
@@ -23,6 +26,9 @@ export default async function App() {
2326
<ShowMore>
2427
<p>Lorem ipsum</p>
2528
</ShowMore>
29+
<div>
30+
<Button action={like}>Like</Button>
31+
</div>
2632
</Container>
2733
);
2834
}

fixtures/flight/src/Button.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
5+
export default function Button({action, children}) {
6+
return (
7+
<button
8+
onClick={async () => {
9+
const result = await action();
10+
console.log(result);
11+
}}>
12+
{children}
13+
</button>
14+
);
15+
}

fixtures/flight/src/actions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
'use server';
2+
3+
export async function like() {
4+
console.log('Like');
5+
return 'Liked';
6+
}

fixtures/flight/src/index.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,22 @@ import {Suspense} from 'react';
33
import ReactDOM from 'react-dom/client';
44
import ReactServerDOMReader from 'react-server-dom-webpack/client';
55

6-
let data = ReactServerDOMReader.createFromFetch(fetch('http://localhost:3001'));
6+
let data = ReactServerDOMReader.createFromFetch(
7+
fetch('http://localhost:3001'),
8+
{
9+
callServer(id, args) {
10+
const response = fetch('http://localhost:3001', {
11+
method: 'POST',
12+
cors: 'cors',
13+
headers: {
14+
'rsc-action': JSON.stringify({filepath: id.id, name: id.name}),
15+
},
16+
body: JSON.stringify(args),
17+
});
18+
return ReactServerDOMReader.createFromFetch(response);
19+
},
20+
}
21+
);
722

823
function Content() {
924
return React.use(data);

fixtures/flight/yarn.lock

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3221,6 +3221,24 @@ body-parser@1.20.0:
32213221
type-is "~1.6.18"
32223222
unpipe "1.0.0"
32233223

3224+
body-parser@^1.20.1:
3225+
version "1.20.1"
3226+
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
3227+
integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==
3228+
dependencies:
3229+
bytes "3.1.2"
3230+
content-type "~1.0.4"
3231+
debug "2.6.9"
3232+
depd "2.0.0"
3233+
destroy "1.2.0"
3234+
http-errors "2.0.0"
3235+
iconv-lite "0.4.24"
3236+
on-finished "2.4.1"
3237+
qs "6.11.0"
3238+
raw-body "2.5.1"
3239+
type-is "~1.6.18"
3240+
unpipe "1.0.0"
3241+
32243242
bonjour-service@^1.0.11:
32253243
version "1.0.13"
32263244
resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.0.13.tgz#4ac003dc1626023252d58adf2946f57e5da450c1"
@@ -7970,6 +7988,13 @@ qs@6.10.3:
79707988
dependencies:
79717989
side-channel "^1.0.4"
79727990

7991+
qs@6.11.0:
7992+
version "6.11.0"
7993+
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
7994+
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
7995+
dependencies:
7996+
side-channel "^1.0.4"
7997+
79737998
quick-lru@^5.1.1:
79747999
version "5.1.1"
79758000
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"

packages/react-client/src/ReactFlightClient.js

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {LazyComponent} from 'react/src/ReactLazy';
1212

1313
import type {
1414
ClientReference,
15-
ModuleMetaData,
15+
ClientReferenceMetadata,
1616
UninitializedModel,
1717
Response,
1818
BundlerConfig,
@@ -29,6 +29,8 @@ import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
2929

3030
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
3131

32+
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
33+
3234
export type JSONValue =
3335
| number
3436
| null
@@ -148,6 +150,7 @@ Chunk.prototype.then = function <T>(
148150

149151
export type ResponseBase = {
150152
_bundlerConfig: BundlerConfig,
153+
_callServer: CallServerCallback,
151154
_chunks: Map<number, SomeChunk<any>>,
152155
...
153156
};
@@ -468,6 +471,28 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
468471
return (error: mixed) => triggerErrorOnChunk(chunk, error);
469472
}
470473

474+
function createServerReferenceProxy<A: Iterable<any>, T>(
475+
response: Response,
476+
metaData: any,
477+
): (...A) => Promise<T> {
478+
const callServer = response._callServer;
479+
const proxy = function (): Promise<T> {
480+
// $FlowFixMe[method-unbinding]
481+
const args = Array.prototype.slice.call(arguments);
482+
const p = metaData.bound;
483+
if (p.status === INITIALIZED) {
484+
const bound = p.value;
485+
return callServer(metaData, bound.concat(args));
486+
}
487+
// Since this is a fake Promise whose .then doesn't chain, we have to wrap it.
488+
// TODO: Remove the wrapper once that's fixed.
489+
return Promise.resolve(p).then(function (bound) {
490+
return callServer(metaData, bound.concat(args));
491+
});
492+
};
493+
return proxy;
494+
}
495+
471496
export function parseModelString(
472497
response: Response,
473498
parentObject: Object,
@@ -499,11 +524,33 @@ export function parseModelString(
499524
return chunk;
500525
}
501526
case 'S': {
527+
// Symbol
502528
return Symbol.for(value.substring(2));
503529
}
504530
case 'P': {
531+
// Server Context Provider
505532
return getOrCreateServerContext(value.substring(2)).Provider;
506533
}
534+
case 'F': {
535+
// Server Reference
536+
const id = parseInt(value.substring(2), 16);
537+
const chunk = getChunk(response, id);
538+
switch (chunk.status) {
539+
case RESOLVED_MODEL:
540+
initializeModelChunk(chunk);
541+
break;
542+
}
543+
// The status might have changed after initialization.
544+
switch (chunk.status) {
545+
case INITIALIZED: {
546+
const metadata = chunk.value;
547+
return createServerReferenceProxy(response, metadata);
548+
}
549+
// We always encode it first in the stream so it won't be pending.
550+
default:
551+
throw chunk.reason;
552+
}
553+
}
507554
default: {
508555
// We assume that anything else is a reference ID.
509556
const id = parseInt(value.substring(1), 16);
@@ -551,10 +598,21 @@ export function parseModelTuple(
551598
return value;
552599
}
553600

554-
export function createResponse(bundlerConfig: BundlerConfig): ResponseBase {
601+
function missingCall() {
602+
throw new Error(
603+
'Trying to call a function from "use server" but the callServer option ' +
604+
'was not implemented in your router runtime.',
605+
);
606+
}
607+
608+
export function createResponse(
609+
bundlerConfig: BundlerConfig,
610+
callServer: void | CallServerCallback,
611+
): ResponseBase {
555612
const chunks: Map<number, SomeChunk<any>> = new Map();
556613
const response = {
557614
_bundlerConfig: bundlerConfig,
615+
_callServer: callServer !== undefined ? callServer : missingCall,
558616
_chunks: chunks,
559617
};
560618
return response;
@@ -581,16 +639,19 @@ export function resolveModule(
581639
): void {
582640
const chunks = response._chunks;
583641
const chunk = chunks.get(id);
584-
const moduleMetaData: ModuleMetaData = parseModel(response, model);
585-
const moduleReference = resolveClientReference<$FlowFixMe>(
642+
const clientReferenceMetadata: ClientReferenceMetadata = parseModel(
643+
response,
644+
model,
645+
);
646+
const clientReference = resolveClientReference<$FlowFixMe>(
586647
response._bundlerConfig,
587-
moduleMetaData,
648+
clientReferenceMetadata,
588649
);
589650

590651
// TODO: Add an option to encode modules that are lazy loaded.
591652
// For now we preload all modules as early as possible since it's likely
592653
// that we'll need them.
593-
const promise = preloadModule(moduleReference);
654+
const promise = preloadModule(clientReference);
594655
if (promise) {
595656
let blockedChunk: BlockedChunk<any>;
596657
if (!chunk) {
@@ -605,16 +666,16 @@ export function resolveModule(
605666
blockedChunk.status = BLOCKED;
606667
}
607668
promise.then(
608-
() => resolveModuleChunk(blockedChunk, moduleReference),
669+
() => resolveModuleChunk(blockedChunk, clientReference),
609670
error => triggerErrorOnChunk(blockedChunk, error),
610671
);
611672
} else {
612673
if (!chunk) {
613-
chunks.set(id, createResolvedModuleChunk(response, moduleReference));
674+
chunks.set(id, createResolvedModuleChunk(response, clientReference));
614675
} else {
615676
// This can't actually happen because we don't have any forward
616677
// references to modules.
617-
resolveModuleChunk(chunk, moduleReference);
678+
resolveModuleChunk(chunk, clientReference);
618679
}
619680
}
620681
}

packages/react-client/src/ReactFlightClientStream.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
* @flow
88
*/
99

10+
import type {CallServerCallback} from './ReactFlightClient';
1011
import type {Response} from './ReactFlightClientHostConfigStream';
11-
1212
import type {BundlerConfig} from './ReactFlightClientHostConfig';
1313

1414
import {
@@ -120,11 +120,14 @@ function createFromJSONCallback(response: Response) {
120120
};
121121
}
122122

123-
export function createResponse(bundlerConfig: BundlerConfig): Response {
123+
export function createResponse(
124+
bundlerConfig: BundlerConfig,
125+
callServer: void | CallServerCallback,
126+
): Response {
124127
// NOTE: CHECK THE COMPILER OUTPUT EACH TIME YOU CHANGE THIS.
125128
// It should be inlined to one object literal but minor changes can break it.
126129
const stringDecoder = supportsBinaryStreams ? createStringDecoder() : null;
127-
const response: any = createResponseBase(bundlerConfig);
130+
const response: any = createResponseBase(bundlerConfig, callServer);
128131
response._partialRow = '';
129132
if (supportsBinaryStreams) {
130133
response._stringDecoder = stringDecoder;

0 commit comments

Comments
 (0)