Files
react/fixtures/flight/server/region.js
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

395 lines
12 KiB
JavaScript

'use strict';
// This is a server to host data-local resources like databases and RSC
const path = require('path');
const url = require('url');
const register = require('react-server-dom-webpack/node-register');
register();
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'],
plugins: ['@babel/transform-modules-commonjs'],
sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false,
});
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');
const nodeModule = require('node:module');
app.use(compress());
// Application
const {readFile} = require('fs').promises;
const React = require('react');
const activeDebugChannels =
process.env.NODE_ENV === 'development' ? new Map() : null;
function getDebugChannel(req) {
if (process.env.NODE_ENV !== 'development') {
return undefined;
}
const requestId = req.get('rsc-request-id');
if (!requestId) {
return undefined;
}
return activeDebugChannels.get(requestId);
}
async function renderApp(
res,
returnValue,
formState,
noCache,
promiseForDebugChannel
) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
// const m = require('../src/App.js');
const m = await import('../src/App.js');
let moduleMap;
let mainCSSChunks;
if (process.env.NODE_ENV === 'development') {
// Read the module map from the HMR server in development.
moduleMap = await (
await fetch('http://localhost:3000/react-client-manifest.json')
).json();
mainCSSChunks = (
await (
await fetch('http://localhost:3000/entrypoint-manifest.json')
).json()
).main.css;
} else {
// Read the module map from the static build in production.
moduleMap = JSON.parse(
await readFile(
path.resolve(__dirname, `../build/react-client-manifest.json`),
'utf8'
)
);
mainCSSChunks = JSON.parse(
await readFile(
path.resolve(__dirname, `../build/entrypoint-manifest.json`),
'utf8'
)
).main.css;
}
const App = m.default.default || m.default;
const root = React.createElement(
React.Fragment,
null,
// Prepend the App's tree with stylesheets required for this entrypoint.
mainCSSChunks.map(filename =>
React.createElement('link', {
rel: 'stylesheet',
href: filename,
precedence: 'default',
key: filename,
})
),
React.createElement(App, {noCache})
);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap, {
debugChannel: await promiseForDebugChannel,
});
pipe(res);
}
async function prerenderApp(res, returnValue, formState, noCache) {
const {unstable_prerenderToNodeStream: prerenderToNodeStream} = await import(
'react-server-dom-webpack/static'
);
// const m = require('../src/App.js');
const m = await import('../src/App.js');
let moduleMap;
let mainCSSChunks;
if (process.env.NODE_ENV === 'development') {
// Read the module map from the HMR server in development.
moduleMap = await (
await fetch('http://localhost:3000/react-client-manifest.json')
).json();
mainCSSChunks = (
await (
await fetch('http://localhost:3000/entrypoint-manifest.json')
).json()
).main.css;
} else {
// Read the module map from the static build in production.
moduleMap = JSON.parse(
await readFile(
path.resolve(__dirname, `../build/react-client-manifest.json`),
'utf8'
)
);
mainCSSChunks = JSON.parse(
await readFile(
path.resolve(__dirname, `../build/entrypoint-manifest.json`),
'utf8'
)
).main.css;
}
const App = m.default.default || m.default;
const root = React.createElement(
React.Fragment,
null,
// Prepend the App's tree with stylesheets required for this entrypoint.
mainCSSChunks.map(filename =>
React.createElement('link', {
rel: 'stylesheet',
href: filename,
precedence: 'default',
key: filename,
})
),
React.createElement(App, {prerender: true, noCache})
);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {prelude} = await prerenderToNodeStream(payload, moduleMap);
prelude.pipe(res);
}
app.get('/', async function (req, res) {
const noCache = req.get('cache-control') === 'no-cache';
if ('prerender' in req.query) {
await prerenderApp(res, null, null, noCache);
} else {
await renderApp(res, null, null, noCache, getDebugChannel(req));
}
});
app.post('/', bodyParser.text(), async function (req, res) {
const noCache = req.headers['cache-control'] === 'no-cache';
const {decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState} =
await import('react-server-dom-webpack/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);
req.pipe(bb);
args = await reply;
} else {
args = await decodeReply(req.body);
}
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, null, noCache, getDebugChannel(req));
} 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);
try {
// Wait for any mutations
const result = await action();
const formState = decodeFormState(result, formData);
renderApp(res, null, formState, noCache, undefined);
} catch (x) {
const {setServerState} = await import('../src/ServerState.js');
setServerState('Error: ' + x.message);
renderApp(res, null, null, noCache, undefined);
}
}
});
app.get('/todos', function (req, res) {
res.json([
{
id: 1,
text: 'Shave yaks',
},
{
id: 2,
text: 'Eat kale',
},
]);
});
if (process.env.NODE_ENV === 'development') {
const rootDir = path.resolve(__dirname, '../');
app.get('/source-maps', async function (req, res, next) {
try {
res.set('Content-type', 'application/json');
let requestedFilePath = req.query.name;
let isCompiledOutput = false;
if (requestedFilePath.startsWith('file://')) {
// We assume that if it was prefixed with file:// it's referring to the compiled output
// and if it's a direct file path we assume it's source mapped back to original format.
isCompiledOutput = true;
requestedFilePath = url.fileURLToPath(requestedFilePath);
}
const relativePath = path.relative(rootDir, requestedFilePath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
// This is outside the root directory of the app. Forbid it to be served.
res.status = 403;
res.write('{}');
res.end();
return;
}
const sourceMap = nodeModule.findSourceMap(requestedFilePath);
let map;
if (requestedFilePath.startsWith('node:')) {
// This is a node internal. We don't include any source code for this but we still
// generate a source map for it so that we can add it to an ignoreList automatically.
map = {
version: 3,
// We use the node:// protocol convention to teach Chrome DevTools that this is
// on a different protocol and not part of the current page.
sources: ['node:///' + requestedFilePath.slice(5)],
sourcesContent: ['// Node Internals'],
mappings: 'AAAA',
ignoreList: [0],
sourceRoot: '',
};
} else if (!sourceMap || !isCompiledOutput) {
// If a file doesn't have a source map, such as this file, then we generate a blank
// source map that just contains the original content and segments pointing to the
// original lines. If a line number points to uncompiled output, like if source mapping
// was already applied we also use this path.
const sourceContent = await readFile(requestedFilePath, 'utf8');
const lines = sourceContent.split('\n').length;
// We ensure to absolute
const sourceURL = url.pathToFileURL(requestedFilePath);
map = {
version: 3,
sources: [sourceURL],
sourcesContent: [sourceContent],
// Note: This approach to mapping each line only lets you jump to each line
// not jump to a column within a line. To do that, you need a proper source map
// generated for each parsed segment or add a segment for each column.
mappings: 'AAAA' + ';AACA'.repeat(lines - 1),
sourceRoot: '',
// Add any node_modules to the ignore list automatically.
ignoreList: requestedFilePath.includes('node_modules')
? [0]
: undefined,
};
} else {
// We always set prepareStackTrace before reading the stack so that we get the stack
// without source maps applied. Therefore we have to use the original source map.
// If something read .stack before we did, we might observe the line/column after
// source mapping back to the original file. We use the isCompiledOutput check above
// in that case.
map = sourceMap.payload;
}
res.write(JSON.stringify(map));
res.end();
} catch (x) {
res.status = 500;
res.write('{}');
res.end();
console.error(x);
}
});
}
const httpServer = 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;
}
});
if (process.env.NODE_ENV === 'development') {
// Open a websocket server for Debug information
const WebSocket = require('ws');
const webSocketServer = new WebSocket.Server({noServer: true});
httpServer.on('upgrade', (request, socket, head) => {
const DEBUG_CHANNEL_PATH = '/debug-channel?';
if (request.url.startsWith(DEBUG_CHANNEL_PATH)) {
const requestId = request.url.slice(DEBUG_CHANNEL_PATH.length);
const promiseForWs = new Promise(resolve => {
webSocketServer.handleUpgrade(request, socket, head, ws => {
ws.on('close', () => {
activeDebugChannels.delete(requestId);
});
resolve(ws);
});
});
activeDebugChannels.set(requestId, promiseForWs);
} else {
socket.destroy();
}
});
}