Skip to content

Commit f181ba8

Browse files
authored
[Flight] Add bundler-less version of RSC using plain ESM (#26889)
This isn't really meant to be actually used, there are many issues with this approach, but it shows the capabilities as a proof-of-concept. It's a new reference implementation package `react-server-dom-esm` as well as a fixture in `fixtures/flight-esm` (fork of `fixtures/flight`). This works pretty much the same as pieces we already have in the Webpack implementation but instead of loading modules using Webpack on the client it uses native browser ESM. To really show it off, I don't use any JSX in the fixture and so it also doesn't use Babel or any compilation of the files. This works because we don't actually bundle the server in the reference implementation in the first place. We instead use [Node.js Loaders](https://nodejs.org/api/esm.html#loaders) to intercept files that contain `"use client"` and `"use server"` and replace them. There's a simple check for those exact bytes, and no parsing, so this is very fast. Since the client isn't actually bundled, there's no module map needed. We can just send the file path to the file we want to load in the RSC payload for client references. Since the existing reference implementation for Node.js already used ESM to load modules on the server, that all works the same, including Server Actions. No bundling. There is one case that isn't implemented here. Importing a `"use server"` file from a Client Component. We don't have that implemented in the Webpack reference implementation neither - only in Next.js atm. In Webpack it would be implemented as a Webpack loader. There are a few ways this can be implemented without a bundler: - We can intercept the request from the browser importing this file in the HTTP server, and do a quick scan for `"use server"` in the file and replace it just like we do with loaders in Node.js. This is effectively how Vite works and likely how anyone using this technique would have to support JSX anyway. - We can use native browser "loaders" once that's eventually available in the same way as in Node.js. - We can generate import maps for each file and replace it with a pointer to a placeholder file. This requires scanning these ahead of time which defeats the purposes. Another case that's not implemented is the inline `"use server"` closure in a Server Component. That would require the existing loader to be a bit smarter but would still only "compile" files that contains those bytes in the fast path check. This would also happen in the loader that already exists so wouldn't do anything substantially different than what we currently have here.
1 parent e1ad4aa commit f181ba8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2758
-0
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ module.exports = {
324324
'packages/react-devtools-shared/**/*.js',
325325
'packages/react-noop-renderer/**/*.js',
326326
'packages/react-refresh/**/*.js',
327+
'packages/react-server-dom-esm/**/*.js',
327328
'packages/react-server-dom-webpack/**/*.js',
328329
'packages/react-test-renderer/**/*.js',
329330
'packages/react-debug-tools/**/*.js',

fixtures/flight-esm/.gitignore

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# misc
12+
.DS_Store
13+
.env.local
14+
.env.development.local
15+
.env.test.local
16+
.env.production.local
17+
18+
npm-debug.log*
19+
yarn-debug.log*
20+
yarn-error.log*
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
resolve,
3+
load as reactLoad,
4+
getSource as getSourceImpl,
5+
transformSource as reactTransformSource,
6+
} from 'react-server-dom-esm/node-loader';
7+
8+
export {resolve};
9+
10+
async function textLoad(url, context, defaultLoad) {
11+
const {format} = context;
12+
const result = await defaultLoad(url, context, defaultLoad);
13+
if (result.format === 'module') {
14+
if (typeof result.source === 'string') {
15+
return result;
16+
}
17+
return {
18+
source: Buffer.from(result.source).toString('utf8'),
19+
format: 'module',
20+
};
21+
}
22+
return result;
23+
}
24+
25+
export async function load(url, context, defaultLoad) {
26+
return await reactLoad(url, context, (u, c) => {
27+
return textLoad(u, c, defaultLoad);
28+
});
29+
}
30+
31+
async function textTransformSource(source, context, defaultTransformSource) {
32+
const {format} = context;
33+
if (format === 'module') {
34+
if (typeof source === 'string') {
35+
return {source};
36+
}
37+
return {
38+
source: Buffer.from(source).toString('utf8'),
39+
};
40+
}
41+
return defaultTransformSource(source, context, defaultTransformSource);
42+
}
43+
44+
async function transformSourceImpl(source, context, defaultTransformSource) {
45+
return await reactTransformSource(source, context, (s, c) => {
46+
return textTransformSource(s, c, defaultTransformSource);
47+
});
48+
}
49+
50+
export const transformSource =
51+
process.version < 'v16' ? transformSourceImpl : undefined;
52+
export const getSource = process.version < 'v16' ? getSourceImpl : undefined;

fixtures/flight-esm/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "flight-esm",
3+
"type": "module",
4+
"version": "0.1.0",
5+
"private": true,
6+
"dependencies": {
7+
"body-parser": "^1.20.1",
8+
"browserslist": "^4.18.1",
9+
"busboy": "^1.6.0",
10+
"compression": "^1.7.4",
11+
"concurrently": "^7.3.0",
12+
"nodemon": "^2.0.19",
13+
"prompts": "^2.4.2",
14+
"react": "experimental",
15+
"react-dom": "experimental",
16+
"undici": "^5.20.0"
17+
},
18+
"scripts": {
19+
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
20+
"prestart": "cp -r ../../build/oss-experimental/* ./node_modules/",
21+
"dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"",
22+
"dev:global": "NODE_ENV=development BUILD_PATH=dist node server/global",
23+
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region",
24+
"start": "concurrently \"npm run start:region\" \"npm run start:global\"",
25+
"start:global": "NODE_ENV=production node server/global",
26+
"start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region"
27+
}
28+
}
24.3 KB
Binary file not shown.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use strict';
2+
3+
// This is a server to host CDN distributed resources like module source files and SSR
4+
5+
const path = require('path');
6+
const url = require('url');
7+
8+
const fs = require('fs').promises;
9+
const compress = require('compression');
10+
const chalk = require('chalk');
11+
const express = require('express');
12+
const http = require('http');
13+
14+
const {renderToPipeableStream} = require('react-dom/server');
15+
const {createFromNodeStream} = require('react-server-dom-esm/client');
16+
17+
const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href;
18+
19+
const app = express();
20+
21+
app.use(compress());
22+
23+
function request(options, body) {
24+
return new Promise((resolve, reject) => {
25+
const req = http.request(options, res => {
26+
resolve(res);
27+
});
28+
req.on('error', e => {
29+
reject(e);
30+
});
31+
body.pipe(req);
32+
});
33+
}
34+
35+
app.all('/', async function (req, res, next) {
36+
// Proxy the request to the regional server.
37+
const proxiedHeaders = {
38+
'X-Forwarded-Host': req.hostname,
39+
'X-Forwarded-For': req.ips,
40+
'X-Forwarded-Port': 3000,
41+
'X-Forwarded-Proto': req.protocol,
42+
};
43+
// Proxy other headers as desired.
44+
if (req.get('rsc-action')) {
45+
proxiedHeaders['Content-type'] = req.get('Content-type');
46+
proxiedHeaders['rsc-action'] = req.get('rsc-action');
47+
} else if (req.get('Content-type')) {
48+
proxiedHeaders['Content-type'] = req.get('Content-type');
49+
}
50+
51+
const promiseForData = request(
52+
{
53+
host: '127.0.0.1',
54+
port: 3001,
55+
method: req.method,
56+
path: '/',
57+
headers: proxiedHeaders,
58+
},
59+
req
60+
);
61+
62+
if (req.accepts('text/html')) {
63+
try {
64+
const rscResponse = await promiseForData;
65+
66+
const moduleBaseURL = '/src';
67+
68+
// For HTML, we're a "client" emulator that runs the client code,
69+
// so we start by consuming the RSC payload. This needs the local file path
70+
// to load the source files from as well as the URL path for preloads.
71+
const root = await createFromNodeStream(
72+
rscResponse,
73+
moduleBasePath,
74+
moduleBaseURL
75+
);
76+
// Render it into HTML by resolving the client components
77+
res.set('Content-type', 'text/html');
78+
const {pipe} = renderToPipeableStream(root, {
79+
// TODO: bootstrapModules inserts a preload before the importmap which causes
80+
// the import map to be invalid. We need to fix that in Float somehow.
81+
// bootstrapModules: ['/src/index.js'],
82+
});
83+
pipe(res);
84+
} catch (e) {
85+
console.error(`Failed to SSR: ${e.stack}`);
86+
res.statusCode = 500;
87+
res.end();
88+
}
89+
} else {
90+
try {
91+
const rscResponse = await promiseForData;
92+
// For other request, we pass-through the RSC payload.
93+
res.set('Content-type', 'text/x-component');
94+
rscResponse.on('data', data => {
95+
res.write(data);
96+
res.flush();
97+
});
98+
rscResponse.on('end', data => {
99+
res.end();
100+
});
101+
} catch (e) {
102+
console.error(`Failed to proxy request: ${e.stack}`);
103+
res.statusCode = 500;
104+
res.end();
105+
}
106+
}
107+
});
108+
109+
app.use(express.static('public'));
110+
app.use('/src', express.static('src'));
111+
app.use(
112+
'/node_modules/react-server-dom-esm/esm',
113+
express.static('node_modules/react-server-dom-esm/esm')
114+
);
115+
116+
app.listen(3000, () => {
117+
console.log('Global Fizz/Webpack Server listening on port 3000...');
118+
});
119+
120+
app.on('error', function (error) {
121+
if (error.syscall !== 'listen') {
122+
throw error;
123+
}
124+
125+
switch (error.code) {
126+
case 'EACCES':
127+
console.error('port 3000 requires elevated privileges');
128+
process.exit(1);
129+
break;
130+
case 'EADDRINUSE':
131+
console.error('Port 3000 is already in use');
132+
process.exit(1);
133+
break;
134+
default:
135+
throw error;
136+
}
137+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "commonjs"
3+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use strict';
2+
3+
// This is a server to host data-local resources like databases and RSC
4+
5+
const path = require('path');
6+
const url = require('url');
7+
8+
if (typeof fetch === 'undefined') {
9+
// Patch fetch for earlier Node versions.
10+
global.fetch = require('undici').fetch;
11+
}
12+
13+
const express = require('express');
14+
const bodyParser = require('body-parser');
15+
const busboy = require('busboy');
16+
const app = express();
17+
const compress = require('compression');
18+
const {Readable} = require('node:stream');
19+
20+
app.use(compress());
21+
22+
// Application
23+
24+
const {readFile} = require('fs').promises;
25+
26+
const React = require('react');
27+
28+
const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href;
29+
30+
async function renderApp(res, returnValue) {
31+
const {renderToPipeableStream} = await import('react-server-dom-esm/server');
32+
const m = await import('../src/App.js');
33+
34+
const App = m.default;
35+
const root = React.createElement(App);
36+
// For client-invoked server actions we refresh the tree and return a return value.
37+
const payload = returnValue ? {returnValue, root} : root;
38+
const {pipe} = renderToPipeableStream(payload, moduleBasePath);
39+
pipe(res);
40+
}
41+
42+
app.get('/', async function (req, res) {
43+
await renderApp(res, null);
44+
});
45+
46+
app.post('/', bodyParser.text(), async function (req, res) {
47+
const {
48+
renderToPipeableStream,
49+
decodeReply,
50+
decodeReplyFromBusboy,
51+
decodeAction,
52+
} = await import('react-server-dom-esm/server');
53+
const serverReference = req.get('rsc-action');
54+
if (serverReference) {
55+
// This is the client-side case
56+
const [filepath, name] = serverReference.split('#');
57+
const action = (await import(filepath))[name];
58+
// Validate that this is actually a function we intended to expose and
59+
// not the client trying to invoke arbitrary functions. In a real app,
60+
// you'd have a manifest verifying this before even importing it.
61+
if (action.$$typeof !== Symbol.for('react.server.reference')) {
62+
throw new Error('Invalid action');
63+
}
64+
65+
let args;
66+
if (req.is('multipart/form-data')) {
67+
// Use busboy to streamingly parse the reply from form-data.
68+
const bb = busboy({headers: req.headers});
69+
const reply = decodeReplyFromBusboy(bb, moduleBasePath);
70+
req.pipe(bb);
71+
args = await reply;
72+
} else {
73+
args = await decodeReply(req.body, moduleBasePath);
74+
}
75+
const result = action.apply(null, args);
76+
try {
77+
// Wait for any mutations
78+
await result;
79+
} catch (x) {
80+
// We handle the error on the client
81+
}
82+
// Refresh the client and return the value
83+
renderApp(res, result);
84+
} else {
85+
// This is the progressive enhancement case
86+
const UndiciRequest = require('undici').Request;
87+
const fakeRequest = new UndiciRequest('http://localhost', {
88+
method: 'POST',
89+
headers: {'Content-Type': req.headers['content-type']},
90+
body: Readable.toWeb(req),
91+
duplex: 'half',
92+
});
93+
const formData = await fakeRequest.formData();
94+
const action = await decodeAction(formData, moduleBasePath);
95+
try {
96+
// Wait for any mutations
97+
await action();
98+
} catch (x) {
99+
const {setServerState} = await import('../src/ServerState.js');
100+
setServerState('Error: ' + x.message);
101+
}
102+
renderApp(res, null);
103+
}
104+
});
105+
106+
app.get('/todos', function (req, res) {
107+
res.json([
108+
{
109+
id: 1,
110+
text: 'Shave yaks',
111+
},
112+
{
113+
id: 2,
114+
text: 'Eat kale',
115+
},
116+
]);
117+
});
118+
119+
app.listen(3001, () => {
120+
console.log('Regional Flight Server listening on port 3001...');
121+
});
122+
123+
app.on('error', function (error) {
124+
if (error.syscall !== 'listen') {
125+
throw error;
126+
}
127+
128+
switch (error.code) {
129+
case 'EACCES':
130+
console.error('port 3001 requires elevated privileges');
131+
process.exit(1);
132+
break;
133+
case 'EADDRINUSE':
134+
console.error('Port 3001 is already in use');
135+
process.exit(1);
136+
break;
137+
default:
138+
throw error;
139+
}
140+
});

0 commit comments

Comments
 (0)