@samitouri / QOS-React-1 / commits / ba099e442b

[Flight] Add findSourceMapURL option to get a URL to load Server source maps from (#29708)

This lets you click a stack frame on the client and see the Server source code inline. <img width="871" alt="Screenshot 2024-06-01 at 11 44 24 PM" src="https://github.com/facebook/react/assets/63648/581281ce-0dce-40c0-a084-4a6d53ba1682"> <img width="840" alt="Screenshot 2024-06-01 at 11 43 37 PM" src="https://github.com/facebook/react/assets/63648/00dc77af-07c1-4389-9ae0-cf1f45199efb"> We could do some logic on the server that sends a source map url for every stack frame in the RSC payload. That would make the client potentially config free. However regardless we need the config to describe what url scheme to use since that’s not built in to the bundler config. In practice you likely have a common pattern for your source maps so no need to send data over and over when we can just have a simple function configured on the client. The server must return a source map, even if the file is not actually compiled since the fake file is still compiled. The source mapping strategy can be one of two models depending on if the server’s stack traces (`new Error().stack`) are source mapped back to the original (`—enable-source-maps`) or represents the location in compiled code (like in the browser). If it represents the location in compiled code it’s actually easier. You just serve the source map generated for that file by the tooling. If it is already source mapped it has to generate a source map where everything points to the same location (as if not compiled) ideally with a segment per logical ast node.

Sebastian Markbåge committed Jun 2, 2024 at 22:58 UTC ba099e442b602b9414693dab9cfa67e19051037c
15 files changed +204 -16
fixtures/flight/config/webpack.config.js
+1 -1
@@ -199,7 +199,7 @@ module.exports = function (webpackEnv) {
199 ? shouldUseSourceMap
200 ? 'source-map'
201 : false
202 - : isEnvDevelopment && 'cheap-module-source-map',
202 + : isEnvDevelopment && 'source-map',
203 // These are the "entry points" to our application.
204 // This means they will be the "root" imports that are included in JS bundle.
205 entry: isEnvProduction
fixtures/flight/loader/region.js
+1
@@ -16,6 +16,7 @@ const babelOptions = {
16 '@babel/plugin-syntax-import-meta',
17 '@babel/plugin-transform-react-jsx',
18 ],
19 + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false,
20 };
21
22 async function babelLoad(url, context, defaultLoad) {
fixtures/flight/package.json
+1 -1
@@ -71,7 +71,7 @@
71 "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/",
72 "dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"",
73 "dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js server/global",
74 - "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region",
74 + "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server server/region",
75 "start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"",
76 "start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global",
77 "start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region",
fixtures/flight/server/global.js
+37
@@ -214,6 +214,43 @@ app.all('/', async function (req, res, next) {
214
215 if (process.env.NODE_ENV === 'development') {
216 app.use(express.static('public'));
217 +
218 + app.get('/source-maps', async function (req, res, next) {
219 + // Proxy the request to the regional server.
220 + const proxiedHeaders = {
221 + 'X-Forwarded-Host': req.hostname,
222 + 'X-Forwarded-For': req.ips,
223 + 'X-Forwarded-Port': 3000,
224 + 'X-Forwarded-Proto': req.protocol,
225 + };
226 +
227 + const promiseForData = request(
228 + {
229 + host: '127.0.0.1',
230 + port: 3001,
231 + method: req.method,
232 + path: req.originalUrl,
233 + headers: proxiedHeaders,
234 + },
235 + req
236 + );
237 +
238 + try {
239 + const rscResponse = await promiseForData;
240 + res.set('Content-type', 'application/json');
241 + rscResponse.on('data', data => {
242 + res.write(data);
243 + res.flush();
244 + });
245 + rscResponse.on('end', data => {
246 + res.end();
247 + });
248 + } catch (e) {
249 + console.error(`Failed to proxy request: ${e.stack}`);
250 + res.statusCode = 500;
251 + res.end();
252 + }
253 + });
254 } else {
255 // In production we host the static build output.
256 app.use(express.static('build'));
fixtures/flight/server/region.js
+66
@@ -24,6 +24,7 @@ babelRegister({
24 ],
25 presets: ['@babel/preset-react'],
26 plugins: ['@babel/transform-modules-commonjs'],
27 + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false,
28 });
29
30 if (typeof fetch === 'undefined') {
@@ -38,6 +39,8 @@ const app = express();
39 const compress = require('compression');
40 const {Readable} = require('node:stream');
41
42 +const nodeModule = require('node:module');
43 +
44 app.use(compress());
45
46 // Application
@@ -176,6 +179,69 @@ app.get('/todos', function (req, res) {
179 ]);
180 });
181
182 +if (process.env.NODE_ENV === 'development') {
183 + const rootDir = path.resolve(__dirname, '../');
184 +
185 + app.get('/source-maps', async function (req, res, next) {
186 + try {
187 + res.set('Content-type', 'application/json');
188 + let requestedFilePath = req.query.name;
189 +
190 + if (requestedFilePath.startsWith('file://')) {
191 + requestedFilePath = requestedFilePath.slice(7);
192 + }
193 +
194 + const relativePath = path.relative(rootDir, requestedFilePath);
195 + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
196 + // This is outside the root directory of the app. Forbid it to be served.
197 + res.status = 403;
198 + res.write('{}');
199 + res.end();
200 + return;
201 + }
202 +
203 + const sourceMap = nodeModule.findSourceMap(requestedFilePath);
204 + let map;
205 + // There are two ways to return a source map depending on what we observe in error.stack.
206 + // A real app will have a similar choice to make for which strategy to pick.
207 + if (!sourceMap || Error.prepareStackTrace === undefined) {
208 + // When --enable-source-maps is enabled, the error.stack that we use to track
209 + // stacks will have had the source map already applied so it's pointing to the
210 + // original source. We return a blank source map that just maps everything to
211 + // the original source in this case.
212 + const sourceContent = await readFile(requestedFilePath, 'utf8');
213 + const lines = sourceContent.split('\n').length;
214 + map = {
215 + version: 3,
216 + sources: [requestedFilePath],
217 + sourcesContent: [sourceContent],
218 + // Note: This approach to mapping each line only lets you jump to each line
219 + // not jump to a column within a line. To do that, you need a proper source map
220 + // generated for each parsed segment or add a segment for each column.
221 + mappings: 'AAAA' + ';AACA'.repeat(lines - 1),
222 + sourceRoot: '',
223 + };
224 + } else {
225 + // If something has overridden prepareStackTrace it is likely not getting the
226 + // natively applied source mapping to error.stack and so the line will point to
227 + // the compiled output similar to how a browser works.
228 + // E.g. ironically this can happen with the source-map-support library that is
229 + // auto-invoked by @babel/register if external source maps are generated.
230 + // In this case we just use the source map that the native source mapping would
231 + // have used.
232 + map = sourceMap.payload;
233 + }
234 + res.write(JSON.stringify(map));
235 + res.end();
236 + } catch (x) {
237 + res.status = 500;
238 + res.write('{}');
239 + res.end();
240 + console.error(x);
241 + }
242 + });
243 +}
244 +
245 app.listen(3001, () => {
246 console.log('Regional Flight Server listening on port 3001...');
247 });
fixtures/flight/src/index.js
+3
@@ -39,6 +39,9 @@ async function hydrateApp() {
39 }),
40 {
41 callServer,
42 + findSourceMapURL(fileName) {
43 + return '/source-maps?name=' + encodeURIComponent(fileName);
44 + },
45 }
46 );
47
packages/react-client/src/ReactFlightClient.js
+31 -6
@@ -239,6 +239,8 @@ Chunk.prototype.then = function <T>(
239 }
240 };
241
242 +export type FindSourceMapURLCallback = (fileName: string) => null | string;
243 +
244 export type Response = {
245 _bundlerConfig: SSRModuleMap,
246 _moduleLoading: ModuleLoading,
@@ -255,6 +257,7 @@ export type Response = {
257 _buffer: Array<Uint8Array>, // chunks received so far as part of this row
258 _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from
259 _debugRootTask?: null | ConsoleTask, // DEV-only
260 + _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only
261 };
262
263 function readChunk<T>(chunk: SomeChunk<T>): T {
@@ -696,7 +699,7 @@ function createElement(
699 console,
700 getTaskName(type),
701 );
699 - const callStack = buildFakeCallStack(stack, createTaskFn);
702 + const callStack = buildFakeCallStack(response, stack, createTaskFn);
703 // This owner should ideally have already been initialized to avoid getting
704 // user stack frames on the stack.
705 const ownerTask =
@@ -1140,6 +1143,7 @@ export function createResponse(
1143 encodeFormAction: void | EncodeFormActionCallback,
1144 nonce: void | string,
1145 temporaryReferences: void | TemporaryReferenceSet,
1146 + findSourceMapURL: void | FindSourceMapURLCallback,
1147 ): Response {
1148 const chunks: Map<number, SomeChunk<any>> = new Map();
1149 const response: Response = {
@@ -1166,6 +1170,9 @@ export function createResponse(
1170 // TODO: Make this string configurable.
1171 response._debugRootTask = (console: any).createTask('"use server"');
1172 }
1173 + if (__DEV__) {
1174 + response._debugFindSourceMapURL = findSourceMapURL;
1175 + }
1176 // Don't inline this call because it causes closure to outline the call above.
1177 response._fromJSON = createFromJSONCallback(response);
1178 return response;
@@ -1673,6 +1680,7 @@ const fakeFunctionCache: Map<string, FakeFunction<any>> = __DEV__
1680 function createFakeFunction<T>(
1681 name: string,
1682 filename: string,
1683 + sourceMap: null | string,
1684 line: number,
1685 col: number,
1686 ): FakeFunction<T> {
@@ -1697,7 +1705,9 @@ function createFakeFunction<T>(
1705 '_()\n';
1706 }
1707
1700 - if (filename) {
1708 + if (sourceMap) {
1709 + code += '//# sourceMappingURL=' + sourceMap;
1710 + } else if (filename) {
1711 code += '//# sourceURL=' + filename;
1712 }
1713
@@ -1720,10 +1730,18 @@ function createFakeFunction<T>(
1730 return fn;
1731 }
1732
1733 +// This matches either of these V8 formats.
1734 +// at name (filename:0:0)
1735 +// at filename:0:0
1736 +// at async filename:0:0
1737 const frameRegExp =
1724 - /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|([^\)]+):(\d+):(\d+))$/;
1738 + /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/;
1739
1726 -function buildFakeCallStack<T>(stack: string, innerCall: () => T): () => T {
1740 +function buildFakeCallStack<T>(
1741 + response: Response,
1742 + stack: string,
1743 + innerCall: () => T,
1744 +): () => T {
1745 const frames = stack.split('\n');
1746 let callStack = innerCall;
1747 for (let i = 0; i < frames.length; i++) {
@@ -1739,7 +1757,13 @@ function buildFakeCallStack<T>(stack: string, innerCall: () => T): () => T {
1757 const filename = parsed[2] || parsed[5] || '';
1758 const line = +(parsed[3] || parsed[6]);
1759 const col = +(parsed[4] || parsed[7]);
1742 - fn = createFakeFunction(name, filename, line, col);
1760 + const sourceMap = response._debugFindSourceMapURL
1761 + ? response._debugFindSourceMapURL(filename)
1762 + : null;
1763 + fn = createFakeFunction(name, filename, sourceMap, line, col);
1764 + // TODO: This cache should technically live on the response since the _debugFindSourceMapURL
1765 + // function is an input and can vary by response.
1766 + fakeFunctionCache.set(frame, fn);
1767 }
1768 callStack = fn.bind(null, callStack);
1769 }
@@ -1770,7 +1794,7 @@ function initializeFakeTask(
1794 console,
1795 getServerComponentTaskName(componentInfo),
1796 );
1773 - const callStack = buildFakeCallStack(stack, createTaskFn);
1797 + const callStack = buildFakeCallStack(response, stack, createTaskFn);
1798
1799 if (ownerTask === null) {
1800 const rootTask = response._debugRootTask;
@@ -1832,6 +1856,7 @@ function resolveConsoleEntry(
1856 return;
1857 }
1858 const callStack = buildFakeCallStack(
1859 + response,
1860 stackTrace,
1861 printToConsole.bind(null, methodName, args, env),
1862 );
packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable} from 'shared/ReactTypes.js';
11
12 -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response as FlightResponse,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
18
@@ -38,6 +41,7 @@ export type Options = {
41 moduleBaseURL?: string,
42 callServer?: CallServerCallback,
43 temporaryReferences?: TemporaryReferenceSet,
44 + findSourceMapURL?: FindSourceMapURLCallback,
45 };
46
47 function createResponseFromOptions(options: void | Options) {
@@ -50,6 +54,9 @@ function createResponseFromOptions(options: void | Options) {
54 options && options.temporaryReferences
55 ? options.temporaryReferences
56 : undefined,
57 + __DEV__ && options && options.findSourceMapURL
58 + ? options.findSourceMapURL
59 + : undefined,
60 );
61 }
62
packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 -import type {Response} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {Readable} from 'stream';
18
@@ -46,6 +49,7 @@ type EncodeFormActionCallback = <A>(
49 export type Options = {
50 nonce?: string,
51 encodeFormAction?: EncodeFormActionCallback,
52 + findSourceMapURL?: FindSourceMapURLCallback,
53 };
54
55 function createFromNodeStream<T>(
@@ -61,6 +65,9 @@ function createFromNodeStream<T>(
65 options ? options.encodeFormAction : undefined,
66 options && typeof options.nonce === 'string' ? options.nonce : undefined,
67 undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
68 + __DEV__ && options && options.findSourceMapURL
69 + ? options.findSourceMapURL
70 + : undefined,
71 );
72 stream.on('data', chunk => {
73 processBinaryChunk(response, chunk);
packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable} from 'shared/ReactTypes.js';
11
12 -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response as FlightResponse,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
18
@@ -37,6 +40,7 @@ type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
40 export type Options = {
41 callServer?: CallServerCallback,
42 temporaryReferences?: TemporaryReferenceSet,
43 + findSourceMapURL?: FindSourceMapURLCallback,
44 };
45
46 function createResponseFromOptions(options: void | Options) {
@@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) {
53 options && options.temporaryReferences
54 ? options.temporaryReferences
55 : undefined,
56 + __DEV__ && options && options.findSourceMapURL
57 + ? options.findSourceMapURL
58 + : undefined,
59 );
60 }
61
packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response as FlightResponse,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
18
@@ -67,6 +70,7 @@ export type Options = {
70 nonce?: string,
71 encodeFormAction?: EncodeFormActionCallback,
72 temporaryReferences?: TemporaryReferenceSet,
73 + findSourceMapURL?: FindSourceMapURLCallback,
74 };
75
76 function createResponseFromOptions(options: Options) {
@@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) {
83 options && options.temporaryReferences
84 ? options.temporaryReferences
85 : undefined,
86 + __DEV__ && options && options.findSourceMapURL
87 + ? options.findSourceMapURL
88 + : undefined,
89 );
90 }
91
packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 -import type {Response} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {
18 SSRModuleMap,
@@ -56,6 +59,7 @@ type EncodeFormActionCallback = <A>(
59 export type Options = {
60 nonce?: string,
61 encodeFormAction?: EncodeFormActionCallback,
62 + findSourceMapURL?: FindSourceMapURLCallback,
63 };
64
65 function createFromNodeStream<T>(
@@ -70,6 +74,9 @@ function createFromNodeStream<T>(
74 options ? options.encodeFormAction : undefined,
75 options && typeof options.nonce === 'string' ? options.nonce : undefined,
76 undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
77 + __DEV__ && options && options.findSourceMapURL
78 + ? options.findSourceMapURL
79 + : undefined,
80 );
81 stream.on('data', chunk => {
82 processBinaryChunk(response, chunk);
packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable} from 'shared/ReactTypes.js';
11
12 -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response as FlightResponse,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
18
@@ -37,6 +40,7 @@ type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
40 export type Options = {
41 callServer?: CallServerCallback,
42 temporaryReferences?: TemporaryReferenceSet,
43 + findSourceMapURL?: FindSourceMapURLCallback,
44 };
45
46 function createResponseFromOptions(options: void | Options) {
@@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) {
53 options && options.temporaryReferences
54 ? options.temporaryReferences
55 : undefined,
56 + __DEV__ && options && options.findSourceMapURL
57 + ? options.findSourceMapURL
58 + : undefined,
59 );
60 }
61
packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response as FlightResponse,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
18
@@ -67,6 +70,7 @@ export type Options = {
70 nonce?: string,
71 encodeFormAction?: EncodeFormActionCallback,
72 temporaryReferences?: TemporaryReferenceSet,
73 + findSourceMapURL?: FindSourceMapURLCallback,
74 };
75
76 function createResponseFromOptions(options: Options) {
@@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) {
83 options && options.temporaryReferences
84 ? options.temporaryReferences
85 : undefined,
86 + __DEV__ && options && options.findSourceMapURL
87 + ? options.findSourceMapURL
88 + : undefined,
89 );
90 }
91
packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js
+8 -1
@@ -9,7 +9,10 @@
9
10 import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js';
11
12 -import type {Response} from 'react-client/src/ReactFlightClient';
12 +import type {
13 + Response,
14 + FindSourceMapURLCallback,
15 +} from 'react-client/src/ReactFlightClient';
16
17 import type {
18 SSRModuleMap,
@@ -56,6 +59,7 @@ type EncodeFormActionCallback = <A>(
59 export type Options = {
60 nonce?: string,
61 encodeFormAction?: EncodeFormActionCallback,
62 + findSourceMapURL?: FindSourceMapURLCallback,
63 };
64
65 function createFromNodeStream<T>(
@@ -70,6 +74,9 @@ function createFromNodeStream<T>(
74 options ? options.encodeFormAction : undefined,
75 options && typeof options.nonce === 'string' ? options.nonce : undefined,
76 undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
77 + __DEV__ && options && options.findSourceMapURL
78 + ? options.findSourceMapURL
79 + : undefined,
80 );
81 stream.on('data', chunk => {
82 processBinaryChunk(response, chunk);