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