From ba099e442b602b9414693dab9cfa67e19051037c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 2 Jun 2024 22:58:24 -0400 Subject: [PATCH] [Flight] Add findSourceMapURL option to get a URL to load Server source maps from (#29708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lets you click a stack frame on the client and see the Server source code inline. Screenshot 2024-06-01 at 11 44 24 PM Screenshot 2024-06-01 at 11 43 37 PM 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. --- fixtures/flight/config/webpack.config.js | 2 +- fixtures/flight/loader/region.js | 1 + fixtures/flight/package.json | 2 +- fixtures/flight/server/global.js | 37 +++++++++++ fixtures/flight/server/region.js | 66 +++++++++++++++++++ fixtures/flight/src/index.js | 3 + .../react-client/src/ReactFlightClient.js | 37 +++++++++-- .../src/ReactFlightDOMClientBrowser.js | 9 ++- .../src/ReactFlightDOMClientNode.js | 9 ++- .../src/ReactFlightDOMClientBrowser.js | 9 ++- .../src/ReactFlightDOMClientEdge.js | 9 ++- .../src/ReactFlightDOMClientNode.js | 9 ++- .../src/ReactFlightDOMClientBrowser.js | 9 ++- .../src/ReactFlightDOMClientEdge.js | 9 ++- .../src/ReactFlightDOMClientNode.js | 9 ++- 15 files changed, 204 insertions(+), 16 deletions(-) diff --git a/fixtures/flight/config/webpack.config.js b/fixtures/flight/config/webpack.config.js index de6eb9916b..665cd37216 100644 --- a/fixtures/flight/config/webpack.config.js +++ b/fixtures/flight/config/webpack.config.js @@ -199,7 +199,7 @@ module.exports = function (webpackEnv) { ? shouldUseSourceMap ? 'source-map' : false - : isEnvDevelopment && 'cheap-module-source-map', + : isEnvDevelopment && 'source-map', // These are the "entry points" to our application. // This means they will be the "root" imports that are included in JS bundle. entry: isEnvProduction diff --git a/fixtures/flight/loader/region.js b/fixtures/flight/loader/region.js index fc2b3ced7e..c81538bc71 100644 --- a/fixtures/flight/loader/region.js +++ b/fixtures/flight/loader/region.js @@ -16,6 +16,7 @@ const babelOptions = { '@babel/plugin-syntax-import-meta', '@babel/plugin-transform-react-jsx', ], + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, }; async function babelLoad(url, context, defaultLoad) { diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index a2d61155ac..cb0f77c8ea 100644 --- a/fixtures/flight/package.json +++ b/fixtures/flight/package.json @@ -71,7 +71,7 @@ "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/", "dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"", "dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js server/global", - "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region", + "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", "start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"", "start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global", "start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region", diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index 779270e16f..e4ae3a6291 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -214,6 +214,43 @@ app.all('/', async function (req, res, next) { if (process.env.NODE_ENV === 'development') { app.use(express.static('public')); + + app.get('/source-maps', async function (req, res, next) { + // Proxy the request to the regional server. + const proxiedHeaders = { + 'X-Forwarded-Host': req.hostname, + 'X-Forwarded-For': req.ips, + 'X-Forwarded-Port': 3000, + 'X-Forwarded-Proto': req.protocol, + }; + + const promiseForData = request( + { + host: '127.0.0.1', + port: 3001, + method: req.method, + path: req.originalUrl, + headers: proxiedHeaders, + }, + req + ); + + try { + const rscResponse = await promiseForData; + res.set('Content-type', 'application/json'); + rscResponse.on('data', data => { + res.write(data); + res.flush(); + }); + rscResponse.on('end', data => { + res.end(); + }); + } catch (e) { + console.error(`Failed to proxy request: ${e.stack}`); + res.statusCode = 500; + res.end(); + } + }); } else { // In production we host the static build output. app.use(express.static('build')); diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index 1064e87e7d..d2136d8b91 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -24,6 +24,7 @@ babelRegister({ ], presets: ['@babel/preset-react'], plugins: ['@babel/transform-modules-commonjs'], + sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false, }); if (typeof fetch === 'undefined') { @@ -38,6 +39,8 @@ const app = express(); const compress = require('compression'); const {Readable} = require('node:stream'); +const nodeModule = require('node:module'); + app.use(compress()); // Application @@ -176,6 +179,69 @@ app.get('/todos', function (req, res) { ]); }); +if (process.env.NODE_ENV === 'development') { + const rootDir = path.resolve(__dirname, '../'); + + app.get('/source-maps', async function (req, res, next) { + try { + res.set('Content-type', 'application/json'); + let requestedFilePath = req.query.name; + + if (requestedFilePath.startsWith('file://')) { + requestedFilePath = requestedFilePath.slice(7); + } + + const relativePath = path.relative(rootDir, requestedFilePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + // This is outside the root directory of the app. Forbid it to be served. + res.status = 403; + res.write('{}'); + res.end(); + return; + } + + const sourceMap = nodeModule.findSourceMap(requestedFilePath); + let map; + // There are two ways to return a source map depending on what we observe in error.stack. + // A real app will have a similar choice to make for which strategy to pick. + if (!sourceMap || Error.prepareStackTrace === undefined) { + // When --enable-source-maps is enabled, the error.stack that we use to track + // stacks will have had the source map already applied so it's pointing to the + // original source. We return a blank source map that just maps everything to + // the original source in this case. + const sourceContent = await readFile(requestedFilePath, 'utf8'); + const lines = sourceContent.split('\n').length; + map = { + version: 3, + sources: [requestedFilePath], + sourcesContent: [sourceContent], + // Note: This approach to mapping each line only lets you jump to each line + // not jump to a column within a line. To do that, you need a proper source map + // generated for each parsed segment or add a segment for each column. + mappings: 'AAAA' + ';AACA'.repeat(lines - 1), + sourceRoot: '', + }; + } else { + // If something has overridden prepareStackTrace it is likely not getting the + // natively applied source mapping to error.stack and so the line will point to + // the compiled output similar to how a browser works. + // E.g. ironically this can happen with the source-map-support library that is + // auto-invoked by @babel/register if external source maps are generated. + // In this case we just use the source map that the native source mapping would + // have used. + map = sourceMap.payload; + } + res.write(JSON.stringify(map)); + res.end(); + } catch (x) { + res.status = 500; + res.write('{}'); + res.end(); + console.error(x); + } + }); +} + app.listen(3001, () => { console.log('Regional Flight Server listening on port 3001...'); }); diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index c888a8a53b..f5b3e7406b 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -39,6 +39,9 @@ async function hydrateApp() { }), { callServer, + findSourceMapURL(fileName) { + return '/source-maps?name=' + encodeURIComponent(fileName); + }, } ); diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index cbbc510a3e..a66c59dc21 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -239,6 +239,8 @@ Chunk.prototype.then = function ( } }; +export type FindSourceMapURLCallback = (fileName: string) => null | string; + export type Response = { _bundlerConfig: SSRModuleMap, _moduleLoading: ModuleLoading, @@ -255,6 +257,7 @@ export type Response = { _buffer: Array, // chunks received so far as part of this row _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from _debugRootTask?: null | ConsoleTask, // DEV-only + _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only }; function readChunk(chunk: SomeChunk): T { @@ -696,7 +699,7 @@ function createElement( console, getTaskName(type), ); - const callStack = buildFakeCallStack(stack, createTaskFn); + const callStack = buildFakeCallStack(response, stack, createTaskFn); // This owner should ideally have already been initialized to avoid getting // user stack frames on the stack. const ownerTask = @@ -1140,6 +1143,7 @@ export function createResponse( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, + findSourceMapURL: void | FindSourceMapURLCallback, ): Response { const chunks: Map> = new Map(); const response: Response = { @@ -1166,6 +1170,9 @@ export function createResponse( // TODO: Make this string configurable. response._debugRootTask = (console: any).createTask('"use server"'); } + if (__DEV__) { + response._debugFindSourceMapURL = findSourceMapURL; + } // Don't inline this call because it causes closure to outline the call above. response._fromJSON = createFromJSONCallback(response); return response; @@ -1673,6 +1680,7 @@ const fakeFunctionCache: Map> = __DEV__ function createFakeFunction( name: string, filename: string, + sourceMap: null | string, line: number, col: number, ): FakeFunction { @@ -1697,7 +1705,9 @@ function createFakeFunction( '_()\n'; } - if (filename) { + if (sourceMap) { + code += '//# sourceMappingURL=' + sourceMap; + } else if (filename) { code += '//# sourceURL=' + filename; } @@ -1720,10 +1730,18 @@ function createFakeFunction( return fn; } +// This matches either of these V8 formats. +// at name (filename:0:0) +// at filename:0:0 +// at async filename:0:0 const frameRegExp = - /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|([^\)]+):(\d+):(\d+))$/; + /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/; -function buildFakeCallStack(stack: string, innerCall: () => T): () => T { +function buildFakeCallStack( + response: Response, + stack: string, + innerCall: () => T, +): () => T { const frames = stack.split('\n'); let callStack = innerCall; for (let i = 0; i < frames.length; i++) { @@ -1739,7 +1757,13 @@ function buildFakeCallStack(stack: string, innerCall: () => T): () => T { const filename = parsed[2] || parsed[5] || ''; const line = +(parsed[3] || parsed[6]); const col = +(parsed[4] || parsed[7]); - fn = createFakeFunction(name, filename, line, col); + const sourceMap = response._debugFindSourceMapURL + ? response._debugFindSourceMapURL(filename) + : null; + fn = createFakeFunction(name, filename, sourceMap, line, col); + // TODO: This cache should technically live on the response since the _debugFindSourceMapURL + // function is an input and can vary by response. + fakeFunctionCache.set(frame, fn); } callStack = fn.bind(null, callStack); } @@ -1770,7 +1794,7 @@ function initializeFakeTask( console, getServerComponentTaskName(componentInfo), ); - const callStack = buildFakeCallStack(stack, createTaskFn); + const callStack = buildFakeCallStack(response, stack, createTaskFn); if (ownerTask === null) { const rootTask = response._debugRootTask; @@ -1832,6 +1856,7 @@ function resolveConsoleEntry( return; } const callStack = buildFakeCallStack( + response, stackTrace, printToConsole.bind(null, methodName, args, env), ); diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js index dbc4430ec1..56d98e6517 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -38,6 +41,7 @@ export type Options = { moduleBaseURL?: string, callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -50,6 +54,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js index 97a9ec0a08..7bcc12d94b 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {Readable} from 'stream'; @@ -46,6 +49,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -61,6 +65,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js index 2f5a554b5a..1aac84fde6 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -37,6 +40,7 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js index 57ed079c5a..c6336f7e42 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -67,6 +70,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: Options) { @@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js index b34958424c..d0fb59c51e 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type { SSRModuleMap, @@ -56,6 +59,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -70,6 +74,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index 2f5a554b5a..1aac84fde6 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -9,7 +9,10 @@ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -37,6 +40,7 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: void | Options) { @@ -49,6 +53,9 @@ function createResponseFromOptions(options: void | Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js index 57ed079c5a..c6336f7e42 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -67,6 +70,7 @@ export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, temporaryReferences?: TemporaryReferenceSet, + findSourceMapURL?: FindSourceMapURLCallback, }; function createResponseFromOptions(options: Options) { @@ -79,6 +83,9 @@ function createResponseFromOptions(options: Options) { options && options.temporaryReferences ? options.temporaryReferences : undefined, + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js index b34958424c..d0fb59c51e 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -9,7 +9,10 @@ import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes.js'; -import type {Response} from 'react-client/src/ReactFlightClient'; +import type { + Response, + FindSourceMapURLCallback, +} from 'react-client/src/ReactFlightClient'; import type { SSRModuleMap, @@ -56,6 +59,7 @@ type EncodeFormActionCallback = ( export type Options = { nonce?: string, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, }; function createFromNodeStream( @@ -70,6 +74,9 @@ function createFromNodeStream( options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, undefined, // TODO: If encodeReply is supported, this should support temporaryReferences + __DEV__ && options && options.findSourceMapURL + ? options.findSourceMapURL + : undefined, ); stream.on('data', chunk => { processBinaryChunk(response, chunk);