From b56c1d554cd2e54d89264258f77f61a2c4f8a31b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 16 Aug 2024 22:34:47 -0400 Subject: [PATCH] Create an eval:ed wrapper around the proxy functions for Server References This ensures that it looks nicer when printed in debug tools and so that you can inspect the function and see the server code location. This assumes that the compiler generates source map locations that map the location of the registerServerReference/createServerReference call to the original function location or at least where the export happened. --- .../react-client/src/ReactFlightClient.js | 39 ++- .../src/ReactFlightReplyClient.js | 248 +++++++++++++++++- .../__tests__/ReactFlightDOMBrowser-test.js | 12 + 3 files changed, 271 insertions(+), 28 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 564f4e859871f..c386d503bfe6b 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -13,6 +13,7 @@ import type { ReactComponentInfo, ReactAsyncInfo, ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; @@ -59,7 +60,7 @@ import { bindToConsole, } from './ReactFlightClientConfig'; -import {registerServerReference} from './ReactFlightReplyClient'; +import {createBoundServerReference} from './ReactFlightReplyClient'; import {readTemporaryReference} from './ReactFlightTemporaryReferences'; @@ -1001,30 +1002,20 @@ function waitForReference( function createServerReferenceProxy, T>( response: Response, - metaData: {id: any, bound: null | Thenable>}, + metaData: { + id: any, + bound: null | Thenable>, + name?: string, // DEV-only + env?: string, // DEV-only + location?: ReactCallSite, // DEV-only + }, ): (...A) => Promise { - const callServer = response._callServer; - const proxy = function (): Promise { - // $FlowFixMe[method-unbinding] - const args = Array.prototype.slice.call(arguments); - const p = metaData.bound; - if (!p) { - return callServer(metaData.id, args); - } - if (p.status === INITIALIZED) { - const bound = p.value; - return callServer(metaData.id, bound.concat(args)); - } - // Since this is a fake Promise whose .then doesn't chain, we have to wrap it. - // TODO: Remove the wrapper once that's fixed. - return ((Promise.resolve(p): any): Promise>).then( - function (bound) { - return callServer(metaData.id, bound.concat(args)); - }, - ); - }; - registerServerReference(proxy, metaData, response._encodeFormAction); - return proxy; + return createBoundServerReference( + metaData, + response._callServer, + response._encodeFormAction, + __DEV__ ? response._debugFindSourceMapURL : undefined, + ); } function getOutlinedModel( diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index c4033a999d96d..233f51844e2a3 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -13,6 +13,7 @@ import type { FulfilledThenable, RejectedThenable, ReactCustomFormAction, + ReactCallSite, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; @@ -1023,7 +1024,99 @@ function isSignatureEqual( } } -export function registerServerReference( +let fakeServerFunctionIdx = 0; + +function createFakeServerFunction, T>( + name: string, + filename: string, + sourceMap: null | string, + line: number, + col: number, + environmentName: string, + innerFunction: (...A) => Promise, +): (...A) => Promise { + // This creates a fake copy of a Server Module. It represents the Server Action on the server. + // We use an eval so we can source map it to the original location. + + const comment = + '/* This module is a proxy to a Server Action. Turn on Source Maps to see the server source. */'; + + if (!name) { + // An eval:ed function with no name gets the name "eval". We give it something more descriptive. + name = ''; + } + const encodedName = JSON.stringify(name); + // We generate code where both the beginning of the function and its parenthesis is at the line + // and column of the server executed code. We use a method form since that lets us name it + // anything we want and because the beginning of the function and its parenthesis is the same + // column. Because Chrome inspects the location of the parenthesis and Firefox inspects the + // location of the beginning of the function. By not using a function expression we avoid the + // ambiguity. + let code; + if (line <= 1) { + const minSize = encodedName.length + 7; + code = + 's=>({' + + encodedName + + ' '.repeat(col < minSize ? 0 : col - minSize) + + ':' + + '(...args) => s(...args)' + + '})\n' + + comment; + } else { + code = + comment + + '\n'.repeat(line - 2) + + 'server=>({' + + encodedName + + ':\n' + + ' '.repeat(col < 1 ? 0 : col - 1) + + // The function body can get printed so we make it look nice. + // This "calls the server with the arguments". + '(...args) => server(...args)' + + '})'; + } + + if (filename.startsWith('/')) { + // If the filename starts with `/` we assume that it is a file system file + // rather than relative to the current host. Since on the server fully qualified + // stack traces use the file path. + // TODO: What does this look like on Windows? + filename = 'file://' + filename; + } + + if (sourceMap) { + // We use the prefix rsc://React/ to separate these from other files listed in + // the Chrome DevTools. We need a "host name" and not just a protocol because + // otherwise the group name becomes the root folder. Ideally we don't want to + // show these at all but there's two reasons to assign a fake URL. + // 1) A printed stack trace string needs a unique URL to be able to source map it. + // 2) If source maps are disabled or fails, you should at least be able to tell + // which file it was. + code += + '\n//# sourceURL=rsc://React/' + + encodeURIComponent(environmentName) + + '/' + + filename + + '?s' + // We add an extra s here to distinguish from the fake stack frames + fakeServerFunctionIdx++; + code += '\n//# sourceMappingURL=' + sourceMap; + } else if (filename) { + code += '\n//# sourceURL=' + filename; + } + + try { + // Eval a factory and then call it to create a closure over the inner function. + // eslint-disable-next-line no-eval + return (0, eval)(code)(innerFunction)[name]; + } catch (x) { + // If eval fails, such as if in an environment that doesn't support it, + // we fallback to just returning the inner function. + return innerFunction; + } +} + +function registerServerReference( proxy: any, reference: {id: ServerReferenceId, bound: null | Thenable>}, encodeFormAction: void | EncodeFormActionCallback, @@ -1098,16 +1191,163 @@ function bind(this: Function): Function { return newFn; } +export type FindSourceMapURLCallback = ( + fileName: string, + environmentName: string, +) => null | string; + +export function createBoundServerReference, T>( + metaData: { + id: ServerReferenceId, + bound: null | Thenable>, + name?: string, // DEV-only + env?: string, // DEV-only + location?: ReactCallSite, // DEV-only + }, + callServer: CallServerCallback, + encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, // DEV-only +): (...A) => Promise { + const id = metaData.id; + const bound = metaData.bound; + let action = function (): Promise { + // $FlowFixMe[method-unbinding] + const args = Array.prototype.slice.call(arguments); + const p = bound; + if (!p) { + return callServer(id, args); + } + if (p.status === 'fulfilled') { + const boundArgs = p.value; + return callServer(id, boundArgs.concat(args)); + } + // Since this is a fake Promise whose .then doesn't chain, we have to wrap it. + // TODO: Remove the wrapper once that's fixed. + return ((Promise.resolve(p): any): Promise>).then( + function (boundArgs) { + return callServer(id, boundArgs.concat(args)); + }, + ); + }; + if (__DEV__) { + const location = metaData.location; + if (location) { + const functionName = metaData.name || ''; + const [, filename, line, col] = location; + const env = metaData.env || 'Server'; + const sourceMap = + findSourceMapURL == null ? null : findSourceMapURL(filename, env); + action = createFakeServerFunction( + functionName, + filename, + sourceMap, + line, + col, + env, + action, + ); + } + } + registerServerReference(action, {id, bound}, encodeFormAction); + return action; +} + +// This matches either of these V8 formats. +// at name (filename:0:0) +// at filename:0:0 +// at async filename:0:0 +const v8FrameRegExp = + /^ {3} at (?:(.+) \((.+):(\d+):(\d+)\)|(?:async )?(.+):(\d+):(\d+))$/; +// This matches either of these JSC/SpiderMonkey formats. +// name@filename:0:0 +// filename:0:0 +const jscSpiderMonkeyFrameRegExp = /(?:(.*)@)?(.*):(\d+):(\d+)/; + +function parseStackLocation(error: Error): null | ReactCallSite { + // This parsing is special in that we know that the calling function will always + // be a module that initializes the server action. We also need this part to work + // cross-browser so not worth a Config. It's DEV only so not super code size + // sensitive but also a non-essential feature. + let stack = error.stack; + if (stack.startsWith('Error: react-stack-top-frame\n')) { + // V8's default formatting prefixes with the error message which we + // don't want/need. + stack = stack.slice(29); + } + const endOfFirst = stack.indexOf('\n'); + let secondFrame; + if (endOfFirst !== -1) { + // Skip the first frame. + const endOfSecond = stack.indexOf('\n', endOfFirst + 1); + if (endOfSecond === -1) { + secondFrame = stack.slice(endOfFirst + 1); + } else { + secondFrame = stack.slice(endOfFirst + 1, endOfSecond); + } + } else { + secondFrame = stack; + } + + let parsed = v8FrameRegExp.exec(secondFrame); + if (!parsed) { + parsed = jscSpiderMonkeyFrameRegExp.exec(secondFrame); + if (!parsed) { + return null; + } + } + + let name = parsed[1] || ''; + if (name === '') { + name = ''; + } + let filename = parsed[2] || parsed[5] || ''; + if (filename === '') { + filename = ''; + } + const line = +(parsed[3] || parsed[6]); + const col = +(parsed[4] || parsed[7]); + + return [name, filename, line, col]; +} + export function createServerReference, T>( id: ServerReferenceId, callServer: CallServerCallback, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, // DEV-only + functionName?: string, ): (...A) => Promise { - const proxy = function (): Promise { + let action = function (): Promise { // $FlowFixMe[method-unbinding] const args = Array.prototype.slice.call(arguments); return callServer(id, args); }; - registerServerReference(proxy, {id, bound: null}, encodeFormAction); - return proxy; + if (__DEV__) { + // Let's see if we can find a source map for the file which contained the + // server action. We extract it from the runtime so that it's resilient to + // multiple passes of compilation as long as we can find the final source map. + const location = parseStackLocation(new Error('react-stack-top-frame')); + if (location !== null) { + const [, filename, line, col] = location; + // While the environment that the Server Reference points to can be + // in any environment, what matters here is where the compiled source + // is from and that's in the currently executing environment. We hard + // code that as the value "Client" in case the findSourceMapURL helper + // needs it. + const env = 'Client'; + const sourceMap = + findSourceMapURL == null ? null : findSourceMapURL(filename, env); + action = createFakeServerFunction( + functionName || '', + filename, + sourceMap, + line, + col, + env, + action, + ); + } + } + registerServerReference(action, {id, bound: null}, encodeFormAction); + return action; } diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 7bbfea1484bed..969f9e125e8c5 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -1393,9 +1393,21 @@ describe('ReactFlightDOMBrowser', () => { const body = await ReactServerDOMClient.encodeReply(args); return callServer(ref, body); }, + undefined, + undefined, + 'upper', ), }; + expect(ServerModuleBImportedOnClient.upper.name).toBe( + __DEV__ ? 'upper' : 'action', + ); + if (__DEV__) { + expect(ServerModuleBImportedOnClient.upper.toString()).toBe( + '(...args) => server(...args)', + ); + } + function Client({action}) { // Client side pass a Server Reference into an action. actionProxy = text => action(ServerModuleBImportedOnClient.upper, text);