Skip to content

Commit b95e2ac

Browse files
committed
Add Flight ESM implementation and fixture
1 parent e1ad4aa commit b95e2ac

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+
}

fixtures/flight-esm/loader/region.js

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.

fixtures/flight-esm/server/global.js

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+
}

fixtures/flight-esm/server/region.js

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.default || 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)