|
| 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 | +app.use(compress()); |
| 21 | + |
| 22 | +// Application |
| 23 | + |
| 24 | +const {readFile} = require('fs').promises; |
| 25 | + |
| 26 | +const React = require('react'); |
| 27 | + |
| 28 | +const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href; |
| 29 | + |
| 30 | +async function renderApp(res, returnValue) { |
| 31 | + const {renderToPipeableStream} = await import('react-server-dom-esm/server'); |
| 32 | + const m = await import('../src/App.js'); |
| 33 | + |
| 34 | + const App = m.default.default || m.default; |
| 35 | + const root = React.createElement(App); |
| 36 | + // For client-invoked server actions we refresh the tree and return a return value. |
| 37 | + const payload = returnValue ? {returnValue, root} : root; |
| 38 | + const {pipe} = renderToPipeableStream(payload, moduleBasePath); |
| 39 | + pipe(res); |
| 40 | +} |
| 41 | + |
| 42 | +app.get('/', async function (req, res) { |
| 43 | + await renderApp(res, null); |
| 44 | +}); |
| 45 | + |
| 46 | +app.post('/', bodyParser.text(), async function (req, res) { |
| 47 | + const { |
| 48 | + renderToPipeableStream, |
| 49 | + decodeReply, |
| 50 | + decodeReplyFromBusboy, |
| 51 | + decodeAction, |
| 52 | + } = await import('react-server-dom-esm/server'); |
| 53 | + const serverReference = req.get('rsc-action'); |
| 54 | + if (serverReference) { |
| 55 | + // This is the client-side case |
| 56 | + const [filepath, name] = serverReference.split('#'); |
| 57 | + const action = (await import(filepath))[name]; |
| 58 | + // Validate that this is actually a function we intended to expose and |
| 59 | + // not the client trying to invoke arbitrary functions. In a real app, |
| 60 | + // you'd have a manifest verifying this before even importing it. |
| 61 | + if (action.$$typeof !== Symbol.for('react.server.reference')) { |
| 62 | + throw new Error('Invalid action'); |
| 63 | + } |
| 64 | + |
| 65 | + let args; |
| 66 | + if (req.is('multipart/form-data')) { |
| 67 | + // Use busboy to streamingly parse the reply from form-data. |
| 68 | + const bb = busboy({headers: req.headers}); |
| 69 | + const reply = decodeReplyFromBusboy(bb, moduleBasePath); |
| 70 | + req.pipe(bb); |
| 71 | + args = await reply; |
| 72 | + } else { |
| 73 | + args = await decodeReply(req.body, moduleBasePath); |
| 74 | + } |
| 75 | + const result = action.apply(null, args); |
| 76 | + try { |
| 77 | + // Wait for any mutations |
| 78 | + await result; |
| 79 | + } catch (x) { |
| 80 | + // We handle the error on the client |
| 81 | + } |
| 82 | + // Refresh the client and return the value |
| 83 | + renderApp(res, result); |
| 84 | + } else { |
| 85 | + // This is the progressive enhancement case |
| 86 | + const UndiciRequest = require('undici').Request; |
| 87 | + const fakeRequest = new UndiciRequest('http://localhost', { |
| 88 | + method: 'POST', |
| 89 | + headers: {'Content-Type': req.headers['content-type']}, |
| 90 | + body: Readable.toWeb(req), |
| 91 | + duplex: 'half', |
| 92 | + }); |
| 93 | + const formData = await fakeRequest.formData(); |
| 94 | + const action = await decodeAction(formData, moduleBasePath); |
| 95 | + try { |
| 96 | + // Wait for any mutations |
| 97 | + await action(); |
| 98 | + } catch (x) { |
| 99 | + const {setServerState} = await import('../src/ServerState.js'); |
| 100 | + setServerState('Error: ' + x.message); |
| 101 | + } |
| 102 | + renderApp(res, null); |
| 103 | + } |
| 104 | +}); |
| 105 | + |
| 106 | +app.get('/todos', function (req, res) { |
| 107 | + res.json([ |
| 108 | + { |
| 109 | + id: 1, |
| 110 | + text: 'Shave yaks', |
| 111 | + }, |
| 112 | + { |
| 113 | + id: 2, |
| 114 | + text: 'Eat kale', |
| 115 | + }, |
| 116 | + ]); |
| 117 | +}); |
| 118 | + |
| 119 | +app.listen(3001, () => { |
| 120 | + console.log('Regional Flight Server listening on port 3001...'); |
| 121 | +}); |
| 122 | + |
| 123 | +app.on('error', function (error) { |
| 124 | + if (error.syscall !== 'listen') { |
| 125 | + throw error; |
| 126 | + } |
| 127 | + |
| 128 | + switch (error.code) { |
| 129 | + case 'EACCES': |
| 130 | + console.error('port 3001 requires elevated privileges'); |
| 131 | + process.exit(1); |
| 132 | + break; |
| 133 | + case 'EADDRINUSE': |
| 134 | + console.error('Port 3001 is already in use'); |
| 135 | + process.exit(1); |
| 136 | + break; |
| 137 | + default: |
| 138 | + throw error; |
| 139 | + } |
| 140 | +}); |
0 commit comments