diff --git a/fixtures/flight/config/webpack.config.js b/fixtures/flight/config/webpack.config.js index de6eb9916bbf..665cd37216d6 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 fc2b3ced7ec5..c81538bc714d 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 a2d61155ac66..cb0f77c8ea5a 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 779270e16f44..e4ae3a62916a 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 1064e87e7d4b..d2136d8b91a4 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 c888a8a53bf3..f5b3e7406b34 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 cbbc510a3e2f..a66c59dc2143 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 dbc4430ec1d8..56d98e65176d 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 97a9ec0a0847..7bcc12d94b08 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 2f5a554b5ac4..1aac84fde677 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 57ed079c5af2..c6336f7e4210 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 b34958424c1c..d0fb59c51e8b 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 2f5a554b5ac4..1aac84fde677 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 57ed079c5af2..c6336f7e4210 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 b34958424c1c..d0fb59c51e8b 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);