main
js 224 lines 7.36 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 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 });