main
js 399 lines 12.4 KB
Raw
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 const register = require('react-server-dom-unbundled/node-register');
9 // TODO: This seems to have no effect anymore. Remove?
10 register();
11
12 const babelRegister = require('@babel/register');
13 babelRegister({
14 babelrc: false,
15 ignore: [
16 /\/(build|node_modules)\//,
17 function (file) {
18 if ((path.dirname(file) + '/').startsWith(__dirname + '/')) {
19 // Ignore everything in this folder
20 // because it's a mix of CJS and ESM
21 // and working with raw code is easier.
22 return true;
23 }
24 return false;
25 },
26 ],
27 presets: ['@babel/preset-react'],
28 plugins: ['@babel/transform-modules-commonjs'],
29 sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false,
30 });
31
32 if (typeof fetch === 'undefined') {
33 // Patch fetch for earlier Node versions.
34 global.fetch = require('undici').fetch;
35 }
36
37 const express = require('express');
38 const bodyParser = require('body-parser');
39 const busboy = require('busboy');
40 const app = express();
41 const compress = require('compression');
42 const {Readable} = require('node:stream');
43
44 const nodeModule = require('node:module');
45
46 app.use(compress());
47
48 // Application
49
50 const {readFile} = require('fs').promises;
51
52 const React = require('react');
53
54 const activeDebugChannels =
55 process.env.NODE_ENV === 'development' ? new Map() : null;
56
57 function filterStackFrame(sourceURL, functionName) {
58 return (
59 sourceURL !== '' &&
60 !sourceURL.startsWith('node:') &&
61 !sourceURL.includes('node_modules') &&
62 !sourceURL.endsWith('library.js') &&
63 !sourceURL.includes('/server/region.js')
64 );
65 }
66
67 function getDebugChannel(req) {
68 if (process.env.NODE_ENV !== 'development') {
69 return undefined;
70 }
71 const requestId = req.get('rsc-request-id');
72 if (!requestId) {
73 return undefined;
74 }
75 return activeDebugChannels.get(requestId);
76 }
77
78 async function renderApp(res, returnValue, formState, noCache, debugChannel) {
79 const {renderToPipeableStream} = await import(
80 'react-server-dom-unbundled/server'
81 );
82 // const m = require('../src/App.js');
83 const m = await import('../src/App.js');
84
85 let moduleMap;
86 let mainCSSChunks;
87 if (process.env.NODE_ENV === 'development') {
88 // Read the module map from the HMR server in development.
89 moduleMap = await (
90 await fetch('http://localhost:3000/react-client-manifest.json')
91 ).json();
92 mainCSSChunks = (
93 await (
94 await fetch('http://localhost:3000/entrypoint-manifest.json')
95 ).json()
96 ).main.css;
97 } else {
98 // Read the module map from the static build in production.
99 moduleMap = JSON.parse(
100 await readFile(
101 path.resolve(__dirname, `../build/react-client-manifest.json`),
102 'utf8'
103 )
104 );
105 mainCSSChunks = JSON.parse(
106 await readFile(
107 path.resolve(__dirname, `../build/entrypoint-manifest.json`),
108 'utf8'
109 )
110 ).main.css;
111 }
112 const App = m.default.default || m.default;
113 const root = React.createElement(
114 React.Fragment,
115 null,
116 // Prepend the App's tree with stylesheets required for this entrypoint.
117 mainCSSChunks.map(filename =>
118 React.createElement('link', {
119 rel: 'stylesheet',
120 href: filename,
121 precedence: 'default',
122 key: filename,
123 })
124 ),
125 React.createElement(App, {noCache})
126 );
127 // For client-invoked server actions we refresh the tree and return a return value.
128 const payload = {root, returnValue, formState};
129 const {pipe} = renderToPipeableStream(payload, moduleMap, {
130 debugChannel,
131 filterStackFrame,
132 });
133 pipe(res);
134 }
135
136 async function prerenderApp(res, returnValue, formState, noCache) {
137 const {prerenderToNodeStream} = await import(
138 'react-server-dom-unbundled/static'
139 );
140 // const m = require('../src/App.js');
141 const m = await import('../src/App.js');
142
143 let moduleMap;
144 let mainCSSChunks;
145 if (process.env.NODE_ENV === 'development') {
146 // Read the module map from the HMR server in development.
147 moduleMap = await (
148 await fetch('http://localhost:3000/react-client-manifest.json')
149 ).json();
150 mainCSSChunks = (
151 await (
152 await fetch('http://localhost:3000/entrypoint-manifest.json')
153 ).json()
154 ).main.css;
155 } else {
156 // Read the module map from the static build in production.
157 moduleMap = JSON.parse(
158 await readFile(
159 path.resolve(__dirname, `../build/react-client-manifest.json`),
160 'utf8'
161 )
162 );
163 mainCSSChunks = JSON.parse(
164 await readFile(
165 path.resolve(__dirname, `../build/entrypoint-manifest.json`),
166 'utf8'
167 )
168 ).main.css;
169 }
170 const App = m.default.default || m.default;
171 const root = React.createElement(
172 React.Fragment,
173 null,
174 // Prepend the App's tree with stylesheets required for this entrypoint.
175 mainCSSChunks.map(filename =>
176 React.createElement('link', {
177 rel: 'stylesheet',
178 href: filename,
179 precedence: 'default',
180 key: filename,
181 })
182 ),
183 React.createElement(App, {prerender: true, noCache})
184 );
185 // For client-invoked server actions we refresh the tree and return a return value.
186 const payload = {root, returnValue, formState};
187 const {prelude} = await prerenderToNodeStream(payload, moduleMap, {
188 filterStackFrame,
189 });
190 prelude.pipe(res);
191 }
192
193 app.get('/', async function (req, res) {
194 const noCache = req.get('cache-control') === 'no-cache';
195
196 if ('prerender' in req.query) {
197 await prerenderApp(res, null, null, noCache);
198 } else {
199 await renderApp(res, null, null, noCache, getDebugChannel(req));
200 }
201 });
202
203 app.post('/', bodyParser.text(), async function (req, res) {
204 const noCache = req.headers['cache-control'] === 'no-cache';
205 const {decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState} =
206 await import('react-server-dom-unbundled/server');
207 const serverReference = req.get('rsc-action');
208 if (serverReference) {
209 // This is the client-side case
210 const [filepath, name] = serverReference.split('#');
211 const action = (await import(filepath))[name];
212 // Validate that this is actually a function we intended to expose and
213 // not the client trying to invoke arbitrary functions. In a real app,
214 // you'd have a manifest verifying this before even importing it.
215 if (action.$$typeof !== Symbol.for('react.server.reference')) {
216 throw new Error('Invalid action');
217 }
218
219 let args;
220 if (req.is('multipart/form-data')) {
221 // Use busboy to streamingly parse the reply from form-data.
222 const bb = busboy({headers: req.headers});
223 const reply = decodeReplyFromBusboy(bb);
224 req.pipe(bb);
225 args = await reply;
226 } else {
227 args = await decodeReply(req.body);
228 }
229 const result = action.apply(null, args);
230 try {
231 // Wait for any mutations
232 await result;
233 } catch (x) {
234 // We handle the error on the client
235 }
236 // Refresh the client and return the value
237 renderApp(res, result, null, noCache, getDebugChannel(req));
238 } else {
239 // This is the progressive enhancement case
240 const UndiciRequest = require('undici').Request;
241 const fakeRequest = new UndiciRequest('http://localhost', {
242 method: 'POST',
243 headers: {'Content-Type': req.headers['content-type']},
244 body: Readable.toWeb(req),
245 duplex: 'half',
246 });
247 const formData = await fakeRequest.formData();
248 const action = await decodeAction(formData);
249 try {
250 // Wait for any mutations
251 const result = await action();
252 const formState = decodeFormState(result, formData);
253 renderApp(res, null, formState, noCache, undefined);
254 } catch (x) {
255 const {setServerState} = await import('../src/ServerState.js');
256 setServerState('Error: ' + x.message);
257 renderApp(res, null, null, noCache, undefined);
258 }
259 }
260 });
261
262 app.get('/todos', function (req, res) {
263 res.json([
264 {
265 id: 1,
266 text: 'Shave yaks',
267 },
268 {
269 id: 2,
270 text: 'Eat kale',
271 },
272 ]);
273 });
274
275 if (process.env.NODE_ENV === 'development') {
276 const rootDir = path.resolve(__dirname, '../');
277
278 app.get('/source-maps', async function (req, res, next) {
279 try {
280 res.set('Content-type', 'application/json');
281 let requestedFilePath = req.query.name;
282
283 let isCompiledOutput = false;
284 if (requestedFilePath.startsWith('file://')) {
285 // We assume that if it was prefixed with file:// it's referring to the compiled output
286 // and if it's a direct file path we assume it's source mapped back to original format.
287 isCompiledOutput = true;
288 requestedFilePath = url.fileURLToPath(requestedFilePath);
289 }
290
291 const relativePath = path.relative(rootDir, requestedFilePath);
292 if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
293 // This is outside the root directory of the app. Forbid it to be served.
294 res.status = 403;
295 res.write('{}');
296 res.end();
297 return;
298 }
299
300 const sourceMap = nodeModule.findSourceMap(requestedFilePath);
301 let map;
302 if (requestedFilePath.startsWith('node:')) {
303 // This is a node internal. We don't include any source code for this but we still
304 // generate a source map for it so that we can add it to an ignoreList automatically.
305 map = {
306 version: 3,
307 // We use the node:// protocol convention to teach Chrome DevTools that this is
308 // on a different protocol and not part of the current page.
309 sources: ['node:///' + requestedFilePath.slice(5)],
310 sourcesContent: ['// Node Internals'],
311 mappings: 'AAAA',
312 ignoreList: [0],
313 sourceRoot: '',
314 };
315 } else if (!sourceMap || !isCompiledOutput) {
316 // If a file doesn't have a source map, such as this file, then we generate a blank
317 // source map that just contains the original content and segments pointing to the
318 // original lines. If a line number points to uncompiled output, like if source mapping
319 // was already applied we also use this path.
320 const sourceContent = await readFile(requestedFilePath, 'utf8');
321 const lines = sourceContent.split('\n').length;
322 // We ensure to absolute
323 const sourceURL = url.pathToFileURL(requestedFilePath);
324 map = {
325 version: 3,
326 sources: [sourceURL],
327 sourcesContent: [sourceContent],
328 // Note: This approach to mapping each line only lets you jump to each line
329 // not jump to a column within a line. To do that, you need a proper source map
330 // generated for each parsed segment or add a segment for each column.
331 mappings: 'AAAA' + ';AACA'.repeat(lines - 1),
332 sourceRoot: '',
333 // Add any node_modules to the ignore list automatically.
334 ignoreList: requestedFilePath.includes('node_modules')
335 ? [0]
336 : undefined,
337 };
338 } else {
339 // We always set prepareStackTrace before reading the stack so that we get the stack
340 // without source maps applied. Therefore we have to use the original source map.
341 // If something read .stack before we did, we might observe the line/column after
342 // source mapping back to the original file. We use the isCompiledOutput check above
343 // in that case.
344 map = sourceMap.payload;
345 }
346 res.write(JSON.stringify(map));
347 res.end();
348 } catch (x) {
349 res.status = 500;
350 res.write('{}');
351 res.end();
352 console.error(x);
353 }
354 });
355 }
356
357 const httpServer = app.listen(3001, () => {
358 console.log('Regional Flight Server listening on port 3001...');
359 });
360
361 app.on('error', function (error) {
362 if (error.syscall !== 'listen') {
363 throw error;
364 }
365
366 switch (error.code) {
367 case 'EACCES':
368 console.error('port 3001 requires elevated privileges');
369 process.exit(1);
370 break;
371 case 'EADDRINUSE':
372 console.error('Port 3001 is already in use');
373 process.exit(1);
374 break;
375 default:
376 throw error;
377 }
378 });
379
380 if (process.env.NODE_ENV === 'development') {
381 // Open a websocket server for Debug information
382 const WebSocket = require('ws');
383
384 const webSocketServer = new WebSocket.Server({
385 server: httpServer,
386 path: '/debug-channel',
387 });
388
389 webSocketServer.on('connection', (ws, req) => {
390 const url = new URL(req.url, `http://${req.headers.host}`);
391 const requestId = url.searchParams.get('id');
392
393 activeDebugChannels.set(requestId, ws);
394
395 ws.on('close', (code, reason) => {
396 activeDebugChannels.delete(requestId);
397 });
398 });
399 }