Skip to content

Commit 7eae895

Browse files
authored
Add RSC wrapper library to simplify server and client (#10074)
1 parent 1b46893 commit 7eae895

File tree

22 files changed

+556
-429
lines changed

22 files changed

+556
-429
lines changed

gulpfile.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const babel = require('gulp-babel');
33
const gulp = require('gulp');
44
const path = require('path');
55
const {rimraf} = require('rimraf');
6+
const swc = require('@swc/core');
67
const babelConfig = require('./babel.config.json');
78

89
const IGNORED_PACKAGES = [
@@ -67,7 +68,7 @@ exports.clean = function clean(cb) {
6768
};
6869

6970
exports.default = exports.build = gulp.series(
70-
gulp.parallel(buildBabel, copyOthers),
71+
gulp.parallel(buildBabel, buildRSC, copyOthers),
7172
// Babel reads from package.json so update these after babel has run
7273
paths.packageJson.map(
7374
packageJsonPath =>
@@ -92,6 +93,33 @@ function copyOthers() {
9293
.pipe(gulp.dest(paths.packages));
9394
}
9495

96+
function buildRSC() {
97+
return gulp
98+
.src('packages/utils/rsc/src/*.tsx')
99+
.pipe(
100+
new TapStream(vinyl => {
101+
let result = swc.transformSync(vinyl.contents.toString(), {
102+
filename: vinyl.path,
103+
jsc: {
104+
parser: {
105+
syntax: 'typescript',
106+
tsx: true,
107+
},
108+
target: 'esnext',
109+
experimental: {
110+
emitIsolatedDts: true,
111+
},
112+
},
113+
});
114+
115+
let output = JSON.parse(result.output);
116+
vinyl.contents = Buffer.from(output.__swc_isolated_declarations__);
117+
}),
118+
)
119+
.pipe(renameStream(relative => relative.replace('.tsx', '.d.ts')))
120+
.pipe(gulp.dest('packages/utils/rsc/lib'));
121+
}
122+
95123
function _updatePackageJson(file) {
96124
return gulp
97125
.src(file)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@khanacademy/flow-to-ts": "^0.5.2",
4646
"@napi-rs/cli": "^2.18.3",
4747
"@parcel/babel-register": "*",
48+
"@swc/core": "^1.10.7",
4849
"@types/node": ">= 18",
4950
"buffer": "mischnic/buffer#b8a4fa94",
5051
"cross-env": "^7.0.0",

packages/examples/react-server-components/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@
1818
"start": "node dist/server.js"
1919
},
2020
"dependencies": {
21+
"@parcel/rsc": "*",
2122
"express": "^4.18.2",
2223
"react": "^19",
23-
"react-dom": "^19",
24-
"react-server-dom-parcel": "canary",
25-
"rsc-html-stream": "^0.0.4"
24+
"react-dom": "^19"
2625
}
2726
}
Lines changed: 23 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,34 @@
11
'use client-entry';
22

3-
import {useState, use, startTransition, useInsertionEffect} from 'react';
4-
import ReactDOM from 'react-dom/client';
5-
import {
6-
createFromReadableStream,
7-
createFromFetch,
8-
encodeReply,
9-
setServerCallback,
10-
} from 'react-server-dom-parcel/client';
11-
import {rscStream} from 'rsc-html-stream/client';
12-
13-
// Stream in initial RSC payload embedded in the HTML.
14-
let data = createFromReadableStream(rscStream);
15-
let updateRoot;
16-
17-
// Setup a callback to perform server actions.
18-
// This sends a POST request to the server, and updates the page with the response.
19-
setServerCallback(async function (id, args) {
20-
console.log(id, args);
21-
const response = fetch('/', {
22-
method: 'POST',
23-
headers: {
24-
Accept: 'text/x-component',
25-
'rsc-action-id': id,
26-
},
27-
body: await encodeReply(args),
28-
});
29-
const {result, root} = await createFromFetch(response);
30-
startTransition(() => updateRoot(root));
31-
return result;
32-
});
33-
34-
function Content() {
35-
// Store the current root element in state, along with a callback
36-
// to call once rendering is complete.
37-
let [[root, cb], setRoot] = useState([use(data), null]);
38-
updateRoot = (root, cb) => setRoot([root, cb]);
39-
useInsertionEffect(() => cb?.());
40-
return root;
41-
}
42-
43-
// Hydrate initial page content.
44-
startTransition(() => {
45-
ReactDOM.hydrateRoot(document, <Content />);
3+
import {hydrate, fetchRSC} from '@parcel/rsc/client';
4+
5+
let updateRoot = hydrate({
6+
async handleServerAction(id, args) {
7+
console.log(id, args);
8+
const {result, root} = await fetchRSC('/', {
9+
method: 'POST',
10+
headers: {
11+
'rsc-action-id': id,
12+
},
13+
body: args,
14+
});
15+
updateRoot(root);
16+
return result;
17+
},
18+
onHmrReload() {
19+
navigate(location.pathname);
20+
},
4621
});
4722

4823
// A very simple router. When we navigate, we'll fetch a new RSC payload from the server,
4924
// and in a React transition, stream in the new page. Once complete, we'll pushState to
5025
// update the URL in the browser.
5126
async function navigate(pathname, push) {
52-
let res = fetch(pathname, {
53-
headers: {
54-
Accept: 'text/x-component',
55-
},
56-
});
57-
let root = await createFromFetch(res);
58-
startTransition(() => {
59-
updateRoot(root, () => {
60-
if (push) {
61-
history.pushState(null, '', pathname);
62-
push = false;
63-
}
64-
});
27+
let root = await fetchRSC(pathname);
28+
updateRoot(root, () => {
29+
if (push) {
30+
history.pushState(null, '', pathname);
31+
}
6532
});
6633
}
6734

@@ -91,9 +58,3 @@ document.addEventListener('click', e => {
9158
window.addEventListener('popstate', e => {
9259
navigate(location.pathname);
9360
});
94-
95-
// Intercept HMR window reloads, and do it with RSC instead.
96-
window.addEventListener('parcelhmrreload', e => {
97-
e.preventDefault();
98-
navigate(location.pathname);
99-
});
Lines changed: 12 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,35 @@
1-
// Server dependencies.
21
import express from 'express';
3-
import {Readable} from 'node:stream';
4-
import {renderToReadableStream, loadServerAction, decodeReply, decodeAction} from 'react-server-dom-parcel/server.edge';
5-
import {injectRSCPayload} from 'rsc-html-stream/server';
6-
7-
// Client dependencies, used for SSR.
8-
// These must run in the same environment as client components (e.g. same instance of React).
9-
import {createFromReadableStream} from 'react-server-dom-parcel/client.edge' with {env: 'react-client'};
10-
import {renderToReadableStream as renderHTMLToReadableStream} from 'react-dom/server.edge' with {env: 'react-client'};
11-
import ReactClient from 'react' with {env: 'react-client'};
2+
import {renderRequest, callAction} from '@parcel/rsc/node';
123

134
// Page components. These must have "use server-entry" so they are treated as code splitting entry points.
145
import App from './App';
156
import FilePage from './FilePage';
167

178
const app = express();
189

19-
app.options('/', function (req, res) {
20-
res.setHeader('Allow', 'Allow: GET,HEAD,POST');
21-
res.setHeader('Access-Control-Allow-Origin', '*');
22-
res.setHeader('Access-Control-Allow-Headers', 'rsc-action');
23-
res.end();
24-
});
25-
2610
app.use(express.static('dist'));
2711

2812
app.get('/', async (req, res) => {
29-
await render(req, res, <App />, App.bootstrapScript);
13+
await renderRequest(req, res, <App />, {component: App});
3014
});
3115

3216
app.get('/files/*', async (req, res) => {
33-
await render(req, res, <FilePage file={req.params[0]} />, FilePage.bootstrapScript);
17+
await renderRequest(req, res, <FilePage file={req.params[0]} />, {component: FilePage});
3418
});
3519

3620
app.post('/', async (req, res) => {
3721
let id = req.get('rsc-action-id');
38-
let request = new Request('http://localhost' + req.url, {
39-
method: 'POST',
40-
headers: req.headers,
41-
body: Readable.toWeb(req),
42-
duplex: 'half'
43-
});
44-
45-
if (id) {
46-
let action = await loadServerAction(id);
47-
let body = req.is('multipart/form-data') ? await request.formData() : await request.text();
48-
let args = await decodeReply(body);
49-
let result = action.apply(null, args);
50-
try {
51-
// Wait for any mutations
52-
await result;
53-
} catch (x) {
54-
// We handle the error on the client
22+
try {
23+
let {result} = await callAction(req, id);
24+
let root = <App />;
25+
if (id) {
26+
root = {result, root};
5527
}
56-
57-
await render(req, res, <App />, App.bootstrapScript, result);
58-
} else {
59-
// Form submitted by browser (progressive enhancement).
60-
let formData = await request.formData();
61-
let action = await decodeAction(formData);
62-
try {
63-
// Wait for any mutations
64-
await action();
65-
} catch (err) {
66-
// TODO render error page?
67-
}
68-
await render(req, res, <App />, App.bootstrapScript);
28+
await renderRequest(req, res, root, {component: App});
29+
} catch (err) {
30+
await renderRequest(req, res, <h1>{err.toString()}</h1>);
6931
}
7032
});
7133

72-
async function render(req, res, component, bootstrapScript, actionResult) {
73-
// Render RSC payload.
74-
let root = component;
75-
if (actionResult) {
76-
root = {result: actionResult, root};
77-
}
78-
let stream = renderToReadableStream(root);
79-
if (req.accepts('text/html')) {
80-
res.setHeader('Content-Type', 'text/html');
81-
82-
// Use client react to render the RSC payload to HTML.
83-
let [s1, s2] = stream.tee();
84-
let data;
85-
function Content() {
86-
// Important: this must be constructed inside a component for preinit scripts to be inserted.
87-
data ??= createFromReadableStream(s1);
88-
return ReactClient.use(data);
89-
}
90-
91-
let htmlStream = await renderHTMLToReadableStream(<Content />, {
92-
bootstrapScriptContent: bootstrapScript,
93-
});
94-
let response = htmlStream.pipeThrough(injectRSCPayload(s2));
95-
Readable.fromWeb(response).pipe(res);
96-
} else {
97-
res.set('Content-Type', 'text/x-component');
98-
Readable.fromWeb(stream).pipe(res);
99-
}
100-
}
101-
102-
let server = app.listen(3001);
34+
app.listen(3001);
10335
console.log('Server listening on port 3001');
104-
console.log(import.meta.distDir, import.meta.publicUrl)
105-
106-
if (module.hot) {
107-
module.hot.dispose(() => {
108-
server.close();
109-
});
110-
111-
module.hot.accept();
112-
}
Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,24 @@
11
"use client-entry";
22

3-
import {useState, use, startTransition, useInsertionEffect, ReactElement} from 'react';
4-
import ReactDOM from 'react-dom/client';
5-
import {createFromReadableStream, createFromFetch} from 'react-server-dom-parcel/client';
6-
import {rscStream} from 'rsc-html-stream/client';
3+
import type { ReactNode } from 'react';
4+
import {hydrate, fetchRSC} from '@parcel/rsc/client';
75

8-
// Stream in initial RSC payload embedded in the HTML.
9-
let initialRSCPayload = createFromReadableStream<ReactElement>(rscStream);
10-
let updateRoot: ((root: ReactElement, cb?: (() => void) | null) => void) | null = null;
11-
12-
function Content() {
13-
// Store the current root element in state, along with a callback
14-
// to call once rendering is complete.
15-
let [[root, cb], setRoot] = useState<[ReactElement, (() => void) | null]>([use(initialRSCPayload), null]);
16-
updateRoot = (root, cb) => setRoot([root, cb ?? null]);
17-
useInsertionEffect(() => cb?.());
18-
return root;
19-
}
20-
21-
// Hydrate initial page content.
22-
startTransition(() => {
23-
ReactDOM.hydrateRoot(document, <Content />);
6+
let updateRoot = hydrate({
7+
// Intercept HMR window reloads, and do it with RSC instead.
8+
onHmrReload() {
9+
navigate(location.pathname);
10+
}
2411
});
2512

2613
// A very simple router. When we navigate, we'll fetch a new RSC payload from the server,
2714
// and in a React transition, stream in the new page. Once complete, we'll pushState to
2815
// update the URL in the browser.
2916
async function navigate(pathname: string, push = false) {
30-
let res = fetch(pathname.replace(/\.html$/, '.rsc'));
31-
let root = await createFromFetch<ReactElement>(res);
32-
startTransition(() => {
33-
updateRoot!(root, () => {
34-
if (push) {
35-
history.pushState(null, '', pathname);
36-
push = false;
37-
}
38-
});
17+
let root = await fetchRSC<ReactNode>(pathname.replace(/\.html$/, '.rsc'));
18+
updateRoot(root, () => {
19+
if (push) {
20+
history.pushState(null, '', pathname);
21+
}
3922
});
4023
}
4124

@@ -65,9 +48,3 @@ document.addEventListener('click', e => {
6548
window.addEventListener('popstate', e => {
6649
navigate(location.pathname);
6750
});
68-
69-
// Intercept HMR window reloads, and do it with RSC instead.
70-
window.addEventListener('parcelhmrreload', e => {
71-
e.preventDefault();
72-
navigate(location.pathname);
73-
});

packages/examples/react-static/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@
1414
"build": "parcel build"
1515
},
1616
"dependencies": {
17+
"@parcel/rsc": "*",
1718
"react": "^19",
18-
"react-dom": "^19",
19-
"react-server-dom-parcel": "canary",
20-
"rsc-html-stream": "^0.0.4"
19+
"react-dom": "^19"
2120
}
2221
}

0 commit comments

Comments
 (0)