Files
Sebastian Markbåge bbc13fa17b [Flight] Add Debug Channel option for stateful connection to the backend in DEV (#33627)
This adds plumbing for opening a stream from the Flight Client to the
Flight Server so it can ask for more data on-demand. In this mode, the
Flight Server keeps the connection open as long as the client is still
alive and there's more objects to load. It retains any depth limited
objects so that they can be asked for later. In this first PR it just
releases the object when it's discovered on the server and doesn't
actually lazy load it yet. That's coming in a follow up.

This strategy is built on the model that each request has its own
channel for this. Instead of some global registry. That ensures that
referential identity is preserved within a Request and the Request can
refer to previously written objects by reference.

The fixture implements a WebSocket per request but it doesn't have to be
done that way. It can be multiplexed through an existing WebSocket for
example. The current protocol is just a Readable(Stream) on the server
and WritableStream on the client. It could even be sent through a HTTP
request body if browsers implemented full duplex (which they don't).

This PR only implements the direction of messages from Client to Server.
However, I also plan on adding Debug Channel in the other direction to
allow debug info (optionally) be sent from Server to Client through this
channel instead of through the main RSC request. So the `debugChannel`
option will be able to take writable or readable or both.

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
2025-06-24 11:16:09 -04:00

298 lines
8.4 KiB
JavaScript

'use strict';
// This is a server to host CDN distributed resources like Webpack bundles and SSR
const path = require('path');
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = process.env.NODE_ENV;
const babelRegister = require('@babel/register');
babelRegister({
babelrc: false,
ignore: [
/\/(build|node_modules)\//,
function (file) {
if ((path.dirname(file) + '/').startsWith(__dirname + '/')) {
// Ignore everything in this folder
// because it's a mix of CJS and ESM
// and working with raw code is easier.
return true;
}
return false;
},
],
presets: ['@babel/preset-react'],
});
// Ensure environment variables are read.
require('../config/env');
const fs = require('fs').promises;
const compress = require('compression');
const chalk = require('chalk');
const express = require('express');
const http = require('http');
const React = require('react');
const {renderToPipeableStream} = require('react-dom/server');
const {createFromNodeStream} = require('react-server-dom-webpack/client');
const {PassThrough} = require('stream');
const app = express();
app.use(compress());
if (process.env.NODE_ENV === 'development') {
// In development we host the Webpack server for live bundling.
const webpack = require('webpack');
const webpackMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const getClientEnvironment = require('../config/env');
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
// Create a webpack compiler that is configured with custom messages.
const compiler = webpack(config);
app.use(
webpackMiddleware(compiler, {
publicPath: paths.publicUrlOrPath.slice(0, -1),
serverSideRender: true,
headers: () => {
return {
'Cache-Control': 'no-store, must-revalidate',
};
},
})
);
app.use(webpackHotMiddleware(compiler));
}
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);
});
}
async function renderApp(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');
}
if (req.headers['cache-control']) {
proxiedHeaders['Cache-Control'] = req.get('cache-control');
}
if (req.get('rsc-request-id')) {
proxiedHeaders['rsc-request-id'] = req.get('rsc-request-id');
}
const requestsPrerender = req.path === '/prerender';
const promiseForData = request(
{
host: '127.0.0.1',
port: 3001,
method: req.method,
path: requestsPrerender ? '/?prerender=1' : '/',
headers: proxiedHeaders,
},
req
);
if (req.accepts('text/html')) {
try {
const rscResponse = await promiseForData;
let virtualFs;
let buildPath;
if (process.env.NODE_ENV === 'development') {
const {devMiddleware} = res.locals.webpack;
virtualFs = devMiddleware.outputFileSystem.promises;
buildPath = devMiddleware.stats.toJson().outputPath;
} else {
virtualFs = fs;
buildPath = path.join(__dirname, '../build/');
}
// Read the module map from the virtual file system.
const serverConsumerManifest = JSON.parse(
await virtualFs.readFile(
path.join(buildPath, 'react-ssr-manifest.json'),
'utf8'
)
);
// Read the entrypoints containing the initial JS to bootstrap everything.
// For other pages, the chunks in the RSC payload are enough.
const mainJSChunks = JSON.parse(
await virtualFs.readFile(
path.join(buildPath, 'entrypoint-manifest.json'),
'utf8'
)
).main.js;
// For HTML, we're a "client" emulator that runs the client code,
// so we start by consuming the RSC payload. This needs a module
// map that reverse engineers the client-side path to the SSR path.
// We need to get the formState before we start rendering but we also
// need to run the Flight client inside the render to get all the preloads.
// The API is ambivalent about what's the right one so we need two for now.
// Tee the response into two streams so that we can do both.
const rscResponse1 = new PassThrough();
const rscResponse2 = new PassThrough();
rscResponse.pipe(rscResponse1);
rscResponse.pipe(rscResponse2);
const {formState} = await createFromNodeStream(
rscResponse1,
serverConsumerManifest
);
rscResponse1.end();
let cachedResult;
let Root = () => {
if (!cachedResult) {
// Read this stream inside the render.
cachedResult = createFromNodeStream(
rscResponse2,
serverConsumerManifest
);
}
return React.use(cachedResult).root;
};
// Render it into HTML by resolving the client components
res.set('Content-type', 'text/html');
const {pipe} = renderToPipeableStream(React.createElement(Root), {
bootstrapScripts: mainJSChunks,
formState: formState,
onShellReady() {
pipe(res);
},
onShellError(error) {
const {pipe: pipeError} = renderToPipeableStream(
React.createElement('html', null, React.createElement('body')),
{
bootstrapScripts: mainJSChunks,
}
);
pipeError(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.all('/', renderApp);
app.all('/prerender', renderApp);
if (process.env.NODE_ENV === 'development') {
app.use(express.static('public'));
app.get('/source-maps', 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,
};
const promiseForData = request(
{
host: '127.0.0.1',
port: 3001,
method: req.method,
path: req.originalUrl,
headers: proxiedHeaders,
},
req
);
try {
const rscResponse = await promiseForData;
res.set('Content-type', 'application/json');
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();
}
});
} else {
// In production we host the static build output.
app.use(express.static('build'));
}
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;
}
});