-
Notifications
You must be signed in to change notification settings - Fork 47.3k
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
[Flight] Add bundler-less version of RSC using plain ESM #26889
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||
|
||
# dependencies | ||
/node_modules | ||
/.pnp | ||
.pnp.js | ||
|
||
# testing | ||
/coverage | ||
|
||
# misc | ||
.DS_Store | ||
.env.local | ||
.env.development.local | ||
.env.test.local | ||
.env.production.local | ||
|
||
npm-debug.log* | ||
yarn-debug.log* | ||
yarn-error.log* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"type": "module" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { | ||
resolve, | ||
load as reactLoad, | ||
getSource as getSourceImpl, | ||
transformSource as reactTransformSource, | ||
} from 'react-server-dom-esm/node-loader'; | ||
|
||
export {resolve}; | ||
|
||
async function textLoad(url, context, defaultLoad) { | ||
const {format} = context; | ||
const result = await defaultLoad(url, context, defaultLoad); | ||
if (result.format === 'module') { | ||
if (typeof result.source === 'string') { | ||
return result; | ||
} | ||
return { | ||
source: Buffer.from(result.source).toString('utf8'), | ||
format: 'module', | ||
}; | ||
} | ||
return result; | ||
} | ||
|
||
export async function load(url, context, defaultLoad) { | ||
return await reactLoad(url, context, (u, c) => { | ||
return textLoad(u, c, defaultLoad); | ||
}); | ||
} | ||
|
||
async function textTransformSource(source, context, defaultTransformSource) { | ||
const {format} = context; | ||
if (format === 'module') { | ||
if (typeof source === 'string') { | ||
return {source}; | ||
} | ||
return { | ||
source: Buffer.from(source).toString('utf8'), | ||
}; | ||
} | ||
return defaultTransformSource(source, context, defaultTransformSource); | ||
} | ||
|
||
async function transformSourceImpl(source, context, defaultTransformSource) { | ||
return await reactTransformSource(source, context, (s, c) => { | ||
return textTransformSource(s, c, defaultTransformSource); | ||
}); | ||
} | ||
|
||
export const transformSource = | ||
process.version < 'v16' ? transformSourceImpl : undefined; | ||
export const getSource = process.version < 'v16' ? getSourceImpl : undefined; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
{ | ||
"name": "flight-esm", | ||
"type": "module", | ||
"version": "0.1.0", | ||
"private": true, | ||
"dependencies": { | ||
"body-parser": "^1.20.1", | ||
"browserslist": "^4.18.1", | ||
"busboy": "^1.6.0", | ||
"compression": "^1.7.4", | ||
"concurrently": "^7.3.0", | ||
"nodemon": "^2.0.19", | ||
"prompts": "^2.4.2", | ||
"react": "experimental", | ||
"react-dom": "experimental", | ||
"undici": "^5.20.0" | ||
}, | ||
"scripts": { | ||
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/", | ||
"prestart": "cp -r ../../build/oss-experimental/* ./node_modules/", | ||
"dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"", | ||
"dev:global": "NODE_ENV=development BUILD_PATH=dist node server/global", | ||
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region", | ||
"start": "concurrently \"npm run start:region\" \"npm run start:global\"", | ||
"start:global": "NODE_ENV=production node server/global", | ||
"start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region" | ||
} | ||
} |
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
'use strict'; | ||
|
||
// This is a server to host CDN distributed resources like module source files and SSR | ||
|
||
const path = require('path'); | ||
const url = require('url'); | ||
|
||
const fs = require('fs').promises; | ||
const compress = require('compression'); | ||
const chalk = require('chalk'); | ||
const express = require('express'); | ||
const http = require('http'); | ||
|
||
const {renderToPipeableStream} = require('react-dom/server'); | ||
const {createFromNodeStream} = require('react-server-dom-esm/client'); | ||
|
||
const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href; | ||
|
||
const app = express(); | ||
|
||
app.use(compress()); | ||
|
||
function request(options, body) { | ||
return new Promise((resolve, reject) => { | ||
const req = http.request(options, res => { | ||
resolve(res); | ||
}); | ||
req.on('error', e => { | ||
reject(e); | ||
}); | ||
body.pipe(req); | ||
}); | ||
} | ||
|
||
app.all('/', async function (req, res, next) { | ||
// Proxy the request to the regional server. | ||
const proxiedHeaders = { | ||
'X-Forwarded-Host': req.hostname, | ||
'X-Forwarded-For': req.ips, | ||
'X-Forwarded-Port': 3000, | ||
'X-Forwarded-Proto': req.protocol, | ||
}; | ||
// Proxy other headers as desired. | ||
if (req.get('rsc-action')) { | ||
proxiedHeaders['Content-type'] = req.get('Content-type'); | ||
proxiedHeaders['rsc-action'] = req.get('rsc-action'); | ||
} else if (req.get('Content-type')) { | ||
proxiedHeaders['Content-type'] = req.get('Content-type'); | ||
} | ||
|
||
const promiseForData = request( | ||
{ | ||
host: '127.0.0.1', | ||
port: 3001, | ||
method: req.method, | ||
path: '/', | ||
headers: proxiedHeaders, | ||
}, | ||
req | ||
); | ||
|
||
if (req.accepts('text/html')) { | ||
try { | ||
const rscResponse = await promiseForData; | ||
|
||
const moduleBaseURL = '/src'; | ||
|
||
// For HTML, we're a "client" emulator that runs the client code, | ||
// so we start by consuming the RSC payload. This needs the local file path | ||
// to load the source files from as well as the URL path for preloads. | ||
const root = await createFromNodeStream( | ||
rscResponse, | ||
moduleBasePath, | ||
moduleBaseURL | ||
); | ||
// Render it into HTML by resolving the client components | ||
res.set('Content-type', 'text/html'); | ||
const {pipe} = renderToPipeableStream(root, { | ||
// TODO: bootstrapModules inserts a preload before the importmap which causes | ||
// the import map to be invalid. We need to fix that in Float somehow. | ||
// bootstrapModules: ['/src/index.js'], | ||
}); | ||
pipe(res); | ||
} catch (e) { | ||
console.error(`Failed to SSR: ${e.stack}`); | ||
res.statusCode = 500; | ||
res.end(); | ||
} | ||
} else { | ||
try { | ||
const rscResponse = await promiseForData; | ||
// For other request, we pass-through the RSC payload. | ||
res.set('Content-type', 'text/x-component'); | ||
rscResponse.on('data', data => { | ||
res.write(data); | ||
res.flush(); | ||
}); | ||
rscResponse.on('end', data => { | ||
res.end(); | ||
}); | ||
} catch (e) { | ||
console.error(`Failed to proxy request: ${e.stack}`); | ||
res.statusCode = 500; | ||
res.end(); | ||
} | ||
} | ||
}); | ||
|
||
app.use(express.static('public')); | ||
app.use('/src', express.static('src')); | ||
app.use( | ||
'/node_modules/react-server-dom-esm/esm', | ||
express.static('node_modules/react-server-dom-esm/esm') | ||
); | ||
|
||
app.listen(3000, () => { | ||
console.log('Global Fizz/Webpack Server listening on port 3000...'); | ||
}); | ||
|
||
app.on('error', function (error) { | ||
if (error.syscall !== 'listen') { | ||
throw error; | ||
} | ||
|
||
switch (error.code) { | ||
case 'EACCES': | ||
console.error('port 3000 requires elevated privileges'); | ||
process.exit(1); | ||
break; | ||
case 'EADDRINUSE': | ||
console.error('Port 3000 is already in use'); | ||
process.exit(1); | ||
break; | ||
default: | ||
throw error; | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"type": "commonjs" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
'use strict'; | ||
|
||
// This is a server to host data-local resources like databases and RSC | ||
|
||
const path = require('path'); | ||
const url = require('url'); | ||
|
||
if (typeof fetch === 'undefined') { | ||
// Patch fetch for earlier Node versions. | ||
global.fetch = require('undici').fetch; | ||
} | ||
|
||
const express = require('express'); | ||
const bodyParser = require('body-parser'); | ||
const busboy = require('busboy'); | ||
const app = express(); | ||
const compress = require('compression'); | ||
const {Readable} = require('node:stream'); | ||
|
||
app.use(compress()); | ||
|
||
// Application | ||
|
||
const {readFile} = require('fs').promises; | ||
|
||
const React = require('react'); | ||
|
||
const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href; | ||
|
||
async function renderApp(res, returnValue) { | ||
const {renderToPipeableStream} = await import('react-server-dom-esm/server'); | ||
const m = await import('../src/App.js'); | ||
|
||
const App = m.default; | ||
const root = React.createElement(App); | ||
// For client-invoked server actions we refresh the tree and return a return value. | ||
const payload = returnValue ? {returnValue, root} : root; | ||
const {pipe} = renderToPipeableStream(payload, moduleBasePath); | ||
pipe(res); | ||
} | ||
|
||
app.get('/', async function (req, res) { | ||
await renderApp(res, null); | ||
}); | ||
|
||
app.post('/', bodyParser.text(), async function (req, res) { | ||
const { | ||
renderToPipeableStream, | ||
decodeReply, | ||
decodeReplyFromBusboy, | ||
decodeAction, | ||
} = await import('react-server-dom-esm/server'); | ||
const serverReference = req.get('rsc-action'); | ||
if (serverReference) { | ||
// This is the client-side case | ||
const [filepath, name] = serverReference.split('#'); | ||
const action = (await import(filepath))[name]; | ||
// Validate that this is actually a function we intended to expose and | ||
// not the client trying to invoke arbitrary functions. In a real app, | ||
// you'd have a manifest verifying this before even importing it. | ||
if (action.$$typeof !== Symbol.for('react.server.reference')) { | ||
throw new Error('Invalid action'); | ||
} | ||
|
||
let args; | ||
if (req.is('multipart/form-data')) { | ||
// Use busboy to streamingly parse the reply from form-data. | ||
const bb = busboy({headers: req.headers}); | ||
const reply = decodeReplyFromBusboy(bb, moduleBasePath); | ||
req.pipe(bb); | ||
args = await reply; | ||
} else { | ||
args = await decodeReply(req.body, moduleBasePath); | ||
} | ||
const result = action.apply(null, args); | ||
try { | ||
// Wait for any mutations | ||
await result; | ||
} catch (x) { | ||
// We handle the error on the client | ||
} | ||
// Refresh the client and return the value | ||
renderApp(res, result); | ||
} else { | ||
// This is the progressive enhancement case | ||
const UndiciRequest = require('undici').Request; | ||
const fakeRequest = new UndiciRequest('http://localhost', { | ||
method: 'POST', | ||
headers: {'Content-Type': req.headers['content-type']}, | ||
body: Readable.toWeb(req), | ||
duplex: 'half', | ||
}); | ||
const formData = await fakeRequest.formData(); | ||
const action = await decodeAction(formData, moduleBasePath); | ||
try { | ||
// Wait for any mutations | ||
await action(); | ||
} catch (x) { | ||
const {setServerState} = await import('../src/ServerState.js'); | ||
setServerState('Error: ' + x.message); | ||
} | ||
renderApp(res, null); | ||
} | ||
}); | ||
|
||
app.get('/todos', function (req, res) { | ||
res.json([ | ||
{ | ||
id: 1, | ||
text: 'Shave yaks', | ||
}, | ||
{ | ||
id: 2, | ||
text: 'Eat kale', | ||
}, | ||
]); | ||
}); | ||
|
||
app.listen(3001, () => { | ||
console.log('Regional Flight Server listening on port 3001...'); | ||
}); | ||
|
||
app.on('error', function (error) { | ||
if (error.syscall !== 'listen') { | ||
throw error; | ||
} | ||
|
||
switch (error.code) { | ||
case 'EACCES': | ||
console.error('port 3001 requires elevated privileges'); | ||
process.exit(1); | ||
break; | ||
case 'EADDRINUSE': | ||
console.error('Port 3001 is already in use'); | ||
process.exit(1); | ||
break; | ||
default: | ||
throw error; | ||
} | ||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would require a bundler, right? Just to confirm. Or does the loader do this somehow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's pretty simple because it'll have been tagged as a server reference by the loader. So verifying is trivial. The decodeReply/decodeAction also has this issue atm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The part I'm missing is, importing a file calls its top-level side effects, so isn't this a possible vector if the filename is arbitrary?