| 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 | if (typeof fetch === 'undefined') { |
| 9 | // Patch fetch for earlier Node versions. |
| 10 | global.fetch = require('undici').fetch; |
| 11 | } |
| 12 | |
| 13 | const express = require('express'); |
| 14 | const bodyParser = require('body-parser'); |
| 15 | const busboy = require('busboy'); |
| 16 | const app = express(); |
| 17 | const compress = require('compression'); |
| 18 | const {Readable} = require('node:stream'); |
| 19 | |
| 20 | const nodeModule = require('node:module'); |
| 21 | |
| 22 | app.use(compress()); |
| 23 | |
| 24 | // Application |
| 25 | |
| 26 | const {readFile} = require('fs').promises; |
| 27 | |
| 28 | const React = require('react'); |
| 29 | |
| 30 | const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href; |
| 31 | |
| 32 | async function renderApp(res, returnValue) { |
| 33 | const {renderToPipeableStream} = await import('react-server-dom-esm/server'); |
| 34 | const m = await import('../src/App.js'); |
| 35 | |
| 36 | const App = m.default; |
| 37 | const root = React.createElement(App); |
| 38 | // For client-invoked server actions we refresh the tree and return a return value. |
| 39 | const payload = returnValue ? {returnValue, root} : root; |
| 40 | const {pipe} = renderToPipeableStream(payload, moduleBasePath); |
| 41 | pipe(res); |
| 42 | } |
| 43 | |
| 44 | app.get('/', async function (req, res) { |
| 45 | await renderApp(res, null); |
| 46 | }); |
| 47 | |
| 48 | app.post('/', bodyParser.text(), async function (req, res) { |
| 49 | const { |
| 50 | renderToPipeableStream, |
| 51 | decodeReply, |
| 52 | decodeReplyFromBusboy, |
| 53 | decodeAction, |
| 54 | } = await import('react-server-dom-esm/server'); |
| 55 | const serverReference = req.get('rsc-action'); |
| 56 | if (serverReference) { |
| 57 | // This is the client-side case |
| 58 | const [filepath, name] = serverReference.split('#'); |
| 59 | const action = (await import(filepath))[name]; |
| 60 | // Validate that this is actually a function we intended to expose and |
| 61 | // not the client trying to invoke arbitrary functions. In a real app, |
| 62 | // you'd have a manifest verifying this before even importing it. |
| 63 | if (action.$$typeof !== Symbol.for('react.server.reference')) { |
| 64 | throw new Error('Invalid action'); |
| 65 | } |
| 66 | |
| 67 | let args; |
| 68 | if (req.is('multipart/form-data')) { |
| 69 | // Use busboy to streamingly parse the reply from form-data. |
| 70 | const bb = busboy({headers: req.headers}); |
| 71 | const reply = decodeReplyFromBusboy(bb, moduleBasePath); |
| 72 | req.pipe(bb); |
| 73 | args = await reply; |
| 74 | } else { |
| 75 | args = await decodeReply(req.body, moduleBasePath); |
| 76 | } |
| 77 | const result = action.apply(null, args); |
| 78 | try { |
| 79 | // Wait for any mutations |
| 80 | await result; |
| 81 | } catch (x) { |
| 82 | // We handle the error on the client |
| 83 | } |
| 84 | // Refresh the client and return the value |
| 85 | renderApp(res, result); |
| 86 | } else { |
| 87 | // This is the progressive enhancement case |
| 88 | const UndiciRequest = require('undici').Request; |
| 89 | const fakeRequest = new UndiciRequest('http://localhost', { |
| 90 | method: 'POST', |
| 91 | headers: {'Content-Type': req.headers['content-type']}, |
| 92 | body: Readable.toWeb(req), |
| 93 | duplex: 'half', |
| 94 | }); |
| 95 | const formData = await fakeRequest.formData(); |
| 96 | const action = await decodeAction(formData, moduleBasePath); |
| 97 | try { |
| 98 | // Wait for any mutations |
| 99 | await action(); |
| 100 | } catch (x) { |
| 101 | const {setServerState} = await import('../src/ServerState.js'); |
| 102 | setServerState('Error: ' + x.message); |
| 103 | } |
| 104 | renderApp(res, null); |
| 105 | } |
| 106 | }); |
| 107 | |
| 108 | app.get('/todos', function (req, res) { |
| 109 | res.json([ |
| 110 | { |
| 111 | id: 1, |
| 112 | text: 'Shave yaks', |
| 113 | }, |
| 114 | { |
| 115 | id: 2, |
| 116 | text: 'Eat kale', |
| 117 | }, |
| 118 | ]); |
| 119 | }); |
| 120 | |
| 121 | if (process.env.NODE_ENV === 'development') { |
| 122 | const rootDir = path.resolve(__dirname, '../'); |
| 123 | |
| 124 | app.get('/source-maps', async function (req, res, next) { |
| 125 | try { |
| 126 | res.set('Content-type', 'application/json'); |
| 127 | let requestedFilePath = req.query.name; |
| 128 | |
| 129 | let isCompiledOutput = false; |
| 130 | if (requestedFilePath.startsWith('file://')) { |
| 131 | // We assume that if it was prefixed with file:// it's referring to the compiled output |
| 132 | // and if it's a direct file path we assume it's source mapped back to original format. |
| 133 | isCompiledOutput = true; |
| 134 | requestedFilePath = url.fileURLToPath(requestedFilePath); |
| 135 | } |
| 136 | |
| 137 | const relativePath = path.relative(rootDir, requestedFilePath); |
| 138 | if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { |
| 139 | // This is outside the root directory of the app. Forbid it to be served. |
| 140 | res.status = 403; |
| 141 | res.write('{}'); |
| 142 | res.end(); |
| 143 | return; |
| 144 | } |
| 145 | |
| 146 | const sourceMap = nodeModule.findSourceMap(requestedFilePath); |
| 147 | let map; |
| 148 | if (requestedFilePath.startsWith('node:')) { |
| 149 | // This is a node internal. We don't include any source code for this but we still |
| 150 | // generate a source map for it so that we can add it to an ignoreList automatically. |
| 151 | map = { |
| 152 | version: 3, |
| 153 | // We use the node:// protocol convention to teach Chrome DevTools that this is |
| 154 | // on a different protocol and not part of the current page. |
| 155 | sources: ['node:///' + requestedFilePath.slice(5)], |
| 156 | sourcesContent: ['// Node Internals'], |
| 157 | mappings: 'AAAA', |
| 158 | ignoreList: [0], |
| 159 | sourceRoot: '', |
| 160 | }; |
| 161 | } else if (!sourceMap || !isCompiledOutput) { |
| 162 | // If a file doesn't have a source map, such as this file, then we generate a blank |
| 163 | // source map that just contains the original content and segments pointing to the |
| 164 | // original lines. If a line number points to uncompiled output, like if source mapping |
| 165 | // was already applied we also use this path. |
| 166 | const sourceContent = await readFile(requestedFilePath, 'utf8'); |
| 167 | const lines = sourceContent.split('\n').length; |
| 168 | // We ensure to absolute |
| 169 | const sourceURL = url.pathToFileURL(requestedFilePath); |
| 170 | map = { |
| 171 | version: 3, |
| 172 | sources: [sourceURL], |
| 173 | sourcesContent: [sourceContent], |
| 174 | // Note: This approach to mapping each line only lets you jump to each line |
| 175 | // not jump to a column within a line. To do that, you need a proper source map |
| 176 | // generated for each parsed segment or add a segment for each column. |
| 177 | mappings: 'AAAA' + ';AACA'.repeat(lines - 1), |
| 178 | sourceRoot: '', |
| 179 | // Add any node_modules to the ignore list automatically. |
| 180 | ignoreList: requestedFilePath.includes('node_modules') |
| 181 | ? [0] |
| 182 | : undefined, |
| 183 | }; |
| 184 | } else { |
| 185 | // We always set prepareStackTrace before reading the stack so that we get the stack |
| 186 | // without source maps applied. Therefore we have to use the original source map. |
| 187 | // If something read .stack before we did, we might observe the line/column after |
| 188 | // source mapping back to the original file. We use the isCompiledOutput check above |
| 189 | // in that case. |
| 190 | map = sourceMap.payload; |
| 191 | } |
| 192 | res.write(JSON.stringify(map)); |
| 193 | res.end(); |
| 194 | } catch (x) { |
| 195 | res.status = 500; |
| 196 | res.write('{}'); |
| 197 | res.end(); |
| 198 | console.error(x); |
| 199 | } |
| 200 | }); |
| 201 | } |
| 202 | |
| 203 | app.listen(3001, () => { |
| 204 | console.log('Regional Flight Server listening on port 3001...'); |
| 205 | }); |
| 206 | |
| 207 | app.on('error', function (error) { |
| 208 | if (error.syscall !== 'listen') { |
| 209 | throw error; |
| 210 | } |
| 211 | |
| 212 | switch (error.code) { |
| 213 | case 'EACCES': |
| 214 | console.error('port 3001 requires elevated privileges'); |
| 215 | process.exit(1); |
| 216 | break; |
| 217 | case 'EADDRINUSE': |
| 218 | console.error('Port 3001 is already in use'); |
| 219 | process.exit(1); |
| 220 | break; |
| 221 | default: |
| 222 | throw error; |
| 223 | } |
| 224 | }); |