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

Add RSC wrapper library to simplify server and client #10074

Merged
merged 2 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading