Skip to content

Commit

Permalink
Add RSC wrapper library to simplify server and client (#10074)
Browse files Browse the repository at this point in the history
  • Loading branch information
devongovett authored Jan 21, 2025
1 parent 1b46893 commit 7eae895
Show file tree
Hide file tree
Showing 22 changed files with 556 additions and 429 deletions.
30 changes: 29 additions & 1 deletion gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const babel = require('gulp-babel');
const gulp = require('gulp');
const path = require('path');
const {rimraf} = require('rimraf');
const swc = require('@swc/core');
const babelConfig = require('./babel.config.json');

const IGNORED_PACKAGES = [
Expand Down Expand Up @@ -67,7 +68,7 @@ exports.clean = function clean(cb) {
};

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

function buildRSC() {
return gulp
.src('packages/utils/rsc/src/*.tsx')
.pipe(
new TapStream(vinyl => {
let result = swc.transformSync(vinyl.contents.toString(), {
filename: vinyl.path,
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
target: 'esnext',
experimental: {
emitIsolatedDts: true,
},
},
});

let output = JSON.parse(result.output);
vinyl.contents = Buffer.from(output.__swc_isolated_declarations__);
}),
)
.pipe(renameStream(relative => relative.replace('.tsx', '.d.ts')))
.pipe(gulp.dest('packages/utils/rsc/lib'));
}

function _updatePackageJson(file) {
return gulp
.src(file)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@khanacademy/flow-to-ts": "^0.5.2",
"@napi-rs/cli": "^2.18.3",
"@parcel/babel-register": "*",
"@swc/core": "^1.10.7",
"@types/node": ">= 18",
"buffer": "mischnic/buffer#b8a4fa94",
"cross-env": "^7.0.0",
Expand Down
5 changes: 2 additions & 3 deletions packages/examples/react-server-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@
"start": "node dist/server.js"
},
"dependencies": {
"@parcel/rsc": "*",
"express": "^4.18.2",
"react": "^19",
"react-dom": "^19",
"react-server-dom-parcel": "canary",
"rsc-html-stream": "^0.0.4"
"react-dom": "^19"
}
}
85 changes: 23 additions & 62 deletions packages/examples/react-server-components/src/bootstrap.js
Original file line number Diff line number Diff line change
@@ -1,67 +1,34 @@
'use client-entry';

import {useState, use, startTransition, useInsertionEffect} from 'react';
import ReactDOM from 'react-dom/client';
import {
createFromReadableStream,
createFromFetch,
encodeReply,
setServerCallback,
} from 'react-server-dom-parcel/client';
import {rscStream} from 'rsc-html-stream/client';

// Stream in initial RSC payload embedded in the HTML.
let data = createFromReadableStream(rscStream);
let updateRoot;

// Setup a callback to perform server actions.
// This sends a POST request to the server, and updates the page with the response.
setServerCallback(async function (id, args) {
console.log(id, args);
const response = fetch('/', {
method: 'POST',
headers: {
Accept: 'text/x-component',
'rsc-action-id': id,
},
body: await encodeReply(args),
});
const {result, root} = await createFromFetch(response);
startTransition(() => updateRoot(root));
return result;
});

function Content() {
// Store the current root element in state, along with a callback
// to call once rendering is complete.
let [[root, cb], setRoot] = useState([use(data), null]);
updateRoot = (root, cb) => setRoot([root, cb]);
useInsertionEffect(() => cb?.());
return root;
}

// Hydrate initial page content.
startTransition(() => {
ReactDOM.hydrateRoot(document, <Content />);
import {hydrate, fetchRSC} from '@parcel/rsc/client';

let updateRoot = hydrate({
async handleServerAction(id, args) {
console.log(id, args);
const {result, root} = await fetchRSC('/', {
method: 'POST',
headers: {
'rsc-action-id': id,
},
body: args,
});
updateRoot(root);
return result;
},
onHmrReload() {
navigate(location.pathname);
},
});

// A very simple router. When we navigate, we'll fetch a new RSC payload from the server,
// and in a React transition, stream in the new page. Once complete, we'll pushState to
// update the URL in the browser.
async function navigate(pathname, push) {
let res = fetch(pathname, {
headers: {
Accept: 'text/x-component',
},
});
let root = await createFromFetch(res);
startTransition(() => {
updateRoot(root, () => {
if (push) {
history.pushState(null, '', pathname);
push = false;
}
});
let root = await fetchRSC(pathname);
updateRoot(root, () => {
if (push) {
history.pushState(null, '', pathname);
}
});
}

Expand Down Expand Up @@ -91,9 +58,3 @@ document.addEventListener('click', e => {
window.addEventListener('popstate', e => {
navigate(location.pathname);
});

// Intercept HMR window reloads, and do it with RSC instead.
window.addEventListener('parcelhmrreload', e => {
e.preventDefault();
navigate(location.pathname);
});
101 changes: 12 additions & 89 deletions packages/examples/react-server-components/src/server.js
Original file line number Diff line number Diff line change
@@ -1,112 +1,35 @@
// Server dependencies.
import express from 'express';
import {Readable} from 'node:stream';
import {renderToReadableStream, loadServerAction, decodeReply, decodeAction} from 'react-server-dom-parcel/server.edge';
import {injectRSCPayload} from 'rsc-html-stream/server';

// Client dependencies, used for SSR.
// These must run in the same environment as client components (e.g. same instance of React).
import {createFromReadableStream} from 'react-server-dom-parcel/client.edge' with {env: 'react-client'};
import {renderToReadableStream as renderHTMLToReadableStream} from 'react-dom/server.edge' with {env: 'react-client'};
import ReactClient from 'react' with {env: 'react-client'};
import {renderRequest, callAction} from '@parcel/rsc/node';

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

const app = express();

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.use(express.static('dist'));

app.get('/', async (req, res) => {
await render(req, res, <App />, App.bootstrapScript);
await renderRequest(req, res, <App />, {component: App});
});

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

app.post('/', async (req, res) => {
let id = req.get('rsc-action-id');
let request = new Request('http://localhost' + req.url, {
method: 'POST',
headers: req.headers,
body: Readable.toWeb(req),
duplex: 'half'
});

if (id) {
let action = await loadServerAction(id);
let body = req.is('multipart/form-data') ? await request.formData() : await request.text();
let args = await decodeReply(body);
let result = action.apply(null, args);
try {
// Wait for any mutations
await result;
} catch (x) {
// We handle the error on the client
try {
let {result} = await callAction(req, id);
let root = <App />;
if (id) {
root = {result, root};
}

await render(req, res, <App />, App.bootstrapScript, result);
} else {
// Form submitted by browser (progressive enhancement).
let formData = await request.formData();
let action = await decodeAction(formData);
try {
// Wait for any mutations
await action();
} catch (err) {
// TODO render error page?
}
await render(req, res, <App />, App.bootstrapScript);
await renderRequest(req, res, root, {component: App});
} catch (err) {
await renderRequest(req, res, <h1>{err.toString()}</h1>);
}
});

async function render(req, res, component, bootstrapScript, actionResult) {
// Render RSC payload.
let root = component;
if (actionResult) {
root = {result: actionResult, root};
}
let stream = renderToReadableStream(root);
if (req.accepts('text/html')) {
res.setHeader('Content-Type', 'text/html');

// Use client react to render the RSC payload to HTML.
let [s1, s2] = stream.tee();
let data;
function Content() {
// Important: this must be constructed inside a component for preinit scripts to be inserted.
data ??= createFromReadableStream(s1);
return ReactClient.use(data);
}

let htmlStream = await renderHTMLToReadableStream(<Content />, {
bootstrapScriptContent: bootstrapScript,
});
let response = htmlStream.pipeThrough(injectRSCPayload(s2));
Readable.fromWeb(response).pipe(res);
} else {
res.set('Content-Type', 'text/x-component');
Readable.fromWeb(stream).pipe(res);
}
}

let server = app.listen(3001);
app.listen(3001);
console.log('Server listening on port 3001');
console.log(import.meta.distDir, import.meta.publicUrl)

if (module.hot) {
module.hot.dispose(() => {
server.close();
});

module.hot.accept();
}
47 changes: 12 additions & 35 deletions packages/examples/react-static/components/client.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,24 @@
"use client-entry";

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

// Stream in initial RSC payload embedded in the HTML.
let initialRSCPayload = createFromReadableStream<ReactElement>(rscStream);
let updateRoot: ((root: ReactElement, cb?: (() => void) | null) => void) | null = null;

function Content() {
// Store the current root element in state, along with a callback
// to call once rendering is complete.
let [[root, cb], setRoot] = useState<[ReactElement, (() => void) | null]>([use(initialRSCPayload), null]);
updateRoot = (root, cb) => setRoot([root, cb ?? null]);
useInsertionEffect(() => cb?.());
return root;
}

// Hydrate initial page content.
startTransition(() => {
ReactDOM.hydrateRoot(document, <Content />);
let updateRoot = hydrate({
// Intercept HMR window reloads, and do it with RSC instead.
onHmrReload() {
navigate(location.pathname);
}
});

// A very simple router. When we navigate, we'll fetch a new RSC payload from the server,
// and in a React transition, stream in the new page. Once complete, we'll pushState to
// update the URL in the browser.
async function navigate(pathname: string, push = false) {
let res = fetch(pathname.replace(/\.html$/, '.rsc'));
let root = await createFromFetch<ReactElement>(res);
startTransition(() => {
updateRoot!(root, () => {
if (push) {
history.pushState(null, '', pathname);
push = false;
}
});
let root = await fetchRSC<ReactNode>(pathname.replace(/\.html$/, '.rsc'));
updateRoot(root, () => {
if (push) {
history.pushState(null, '', pathname);
}
});
}

Expand Down Expand Up @@ -65,9 +48,3 @@ document.addEventListener('click', e => {
window.addEventListener('popstate', e => {
navigate(location.pathname);
});

// Intercept HMR window reloads, and do it with RSC instead.
window.addEventListener('parcelhmrreload', e => {
e.preventDefault();
navigate(location.pathname);
});
5 changes: 2 additions & 3 deletions packages/examples/react-static/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@
"build": "parcel build"
},
"dependencies": {
"@parcel/rsc": "*",
"react": "^19",
"react-dom": "^19",
"react-server-dom-parcel": "canary",
"rsc-html-stream": "^0.0.4"
"react-dom": "^19"
}
}
Loading

0 comments on commit 7eae895

Please sign in to comment.