diff --git a/packages/nextjs/src/config/loaders/ast.ts b/packages/nextjs/src/config/loaders/ast.ts index 18f7380553b3..b2e0b62b7afe 100644 --- a/packages/nextjs/src/config/loaders/ast.ts +++ b/packages/nextjs/src/config/loaders/ast.ts @@ -25,14 +25,24 @@ const jscs = jscodeshiftDefault || jscodeshiftNamespace; // These are types not in the TS sense, but in the instance-of-a-Type-class sense const { + ArrayPattern, + ClassDeclaration, + ExportAllDeclaration, + ExportDefaultDeclaration, + ExportDefaultSpecifier, + ExportNamedDeclaration, ExportSpecifier, + FunctionDeclaration, Identifier, ImportSpecifier, + JSXIdentifier, MemberExpression, Node, ObjectExpression, ObjectPattern, Property, + RestElement, + TSTypeParameter, VariableDeclaration, VariableDeclarator, } = jscs; @@ -291,8 +301,6 @@ function maybeRenameNode(ast: AST, identifierPath: ASTPath, alia // it means we potentially need to rename something *not* already named `getServerSideProps`, `getStaticProps`, or // `getStaticPaths`, meaning we need to rename nodes outside of the collection upon which we're currently acting. if (ExportSpecifier.check(parent)) { - // console.log(node); - // debugger; if (parent.exported.name !== parent.local?.name && node === parent.exported) { const currentLocalName = parent.local?.name || ''; renameIdentifiers(ast, currentLocalName, alias); @@ -320,3 +328,202 @@ export function removeComments(ast: AST): void { const nodesWithComments = ast.find(Node).filter(nodePath => !!nodePath.node.comments); nodesWithComments.forEach(nodePath => (nodePath.node.comments = null)); } + +/** + * Determines from a given AST of a file whether the file has a default export or not. + */ +export function hasDefaultExport(ast: AST): boolean { + const defaultExports = ast.find(Node, value => { + return ( + ExportDefaultDeclaration.check(value) || + ExportDefaultSpecifier.check(value) || + (ExportSpecifier.check(value) && value.exported.name === 'default') + ); + }); + + // In theory there should only ever be 0 or 1, but who knows what people do + return defaultExports.length > 0; +} + +/** + * Extracts all identifier names (`'constName'`) from an destructuringassignment'sArrayPattern (the `[constName]` in`const [constName] = [1]`). + * + * This function recursively calls itself and `getExportIdentifiersFromObjectPattern` since destructuring assignments + * can be deeply nested with objects and arrays. + * + * Example - take the following program: + * + * ```js + * export const [{ foo: name1 }, [{ bar: [name2]}, name3]] = [{ foo: 1 }, [{ bar: [2] }, 3]]; + * ``` + * + * The `ArrayPattern` node in question for this program is the left hand side of the assignment: + * `[{ foo: name1 }, [{ bar: [name2]}, name3]]` + * + * Applying this function to this `ArrayPattern` will return the following: `["name1", "name2", "name3"]` + * + * DISCLAIMER: This function only correcly extracts identifiers of `ArrayPatterns` in the context of export statements. + * Using this for `ArrayPattern` outside of exports would require us to handle more edgecases. Hence the "Export" in + * this function's name. + */ +function getExportIdentifiersFromArrayPattern(arrayPattern: jscsTypes.ArrayPattern): string[] { + const identifiers: string[] = []; + + arrayPattern.elements.forEach(element => { + if (Identifier.check(element)) { + identifiers.push(element.name); + } else if (ObjectPattern.check(element)) { + identifiers.push(...getExportIdentifiersFromObjectPattern(element)); + } else if (ArrayPattern.check(element)) { + identifiers.push(...getExportIdentifiersFromArrayPattern(element)); + } else if (RestElement.check(element) && Identifier.check(element.argument)) { + // `RestElements` are spread operators + identifiers.push(element.argument.name); + } + }); + + return identifiers; +} + +/** + * Grabs all identifiers from an ObjectPattern within a destructured named export declaration + * statement (`name` in "export const { val: name } = { val: 1 }"). + * + * This function recursively calls itself and `getExportIdentifiersFromArrayPattern` since destructuring assignments + * can be deeply nested with objects and arrays. + * + * Example - take the following program: + * + * ```js + * export const { foo: [{ bar: name1 }], name2, ...name3 } = { foo: [{}] }; + * ``` + * + * The `ObjectPattern` node in question for this program is the left hand side of the assignment: + * `{ foo: [{ bar: name1 }], name2, ...name3 } = { foo: [{}] }` + * + * Applying this function to this `ObjectPattern` will return the following: `["name1", "name2", "name3"]` + * + * DISCLAIMER: This function only correcly extracts identifiers of `ObjectPatterns` in the context of export statements. + * Using this for `ObjectPatterns` outside of exports would require us to handle more edgecases. Hence the "Export" in + * this function's name. + */ +function getExportIdentifiersFromObjectPattern(objectPatternNode: jscsTypes.ObjectPattern): string[] { + const identifiers: string[] = []; + + objectPatternNode.properties.forEach(property => { + // An `ObjectPattern`'s properties can be either `Property`s or `RestElement`s. + if (Property.check(property)) { + if (Identifier.check(property.value)) { + identifiers.push(property.value.name); + } else if (ObjectPattern.check(property.value)) { + identifiers.push(...getExportIdentifiersFromObjectPattern(property.value)); + } else if (ArrayPattern.check(property.value)) { + identifiers.push(...getExportIdentifiersFromArrayPattern(property.value)); + } else if (RestElement.check(property.value) && Identifier.check(property.value.argument)) { + // `RestElements` are spread operators + identifiers.push(property.value.argument.name); + } + // @ts-ignore AST types are wrong here + } else if (RestElement.check(property) && Identifier.check(property.argument)) { + // `RestElements` are spread operators + // @ts-ignore AST types are wrong here + identifiers.push(property.argument.name as string); + } + }); + + return identifiers; +} + +/** + * Given the AST of a file, this function extracts all named exports from the file. + * + * @returns a list of deduplicated identifiers. + */ +export function getExportIdentifierNames(ast: AST): string[] { + // We'll use a set to dedupe at the end, but for now we use an array as our accumulator because you can add multiple elements to it at once. + const identifiers: string[] = []; + + // The following variable collects all export statements that double as named declaration, e.g.: + // - export function myFunc() {} + // - export var myVar = 1337 + // - export const myConst = 1337 + // - export const { a, ..rest } = { a: 1, b: 2, c: 3 } + // We will narrow those situations down in subsequent code blocks. + const namedExportDeclarationNodeDeclarations = ast + .find(ExportNamedDeclaration) + .nodes() + .map(namedExportDeclarationNode => namedExportDeclarationNode.declaration); + + namedExportDeclarationNodeDeclarations + .filter((declarationNode): declarationNode is jscsTypes.VariableDeclaration => + // Narrow down to varible declarations, e.g.: + // export const a = ...; + // export var b = ...; + // export let c = ...; + // export let c, d = 1; + VariableDeclaration.check(declarationNode), + ) + .map( + variableDeclarationNode => + // Grab all declarations in a single export statement. + // There can be multiple in the case of for example in `export let a, b;`. + variableDeclarationNode.declarations, + ) + .reduce((prev, curr) => [...prev, ...curr], []) // flatten - now we have all declaration nodes in one flat array + .forEach(declarationNode => { + if ( + Identifier.check(declarationNode) || // should never happen + JSXIdentifier.check(declarationNode) || // JSX like `` - we don't care about these + TSTypeParameter.check(declarationNode) // type definitions - we don't care about those + ) { + // We should never have to enter this branch, it is just for type narrowing. + } else if (Identifier.check(declarationNode.id)) { + // If it's a simple declaration with an identifier we collect it. (e.g. `const myIdentifier = 1;` -> "myIdentifier") + identifiers.push(declarationNode.id.name); + } else if (ObjectPattern.check(declarationNode.id)) { + // If we encounter a destructuring export like `export const { foo: name1, bar: name2 } = { foo: 1, bar: 2 };`, + // we try collecting the identifiers from the pattern `{ foo: name1, bar: name2 }`. + identifiers.push(...getExportIdentifiersFromObjectPattern(declarationNode.id)); + } else if (ArrayPattern.check(declarationNode.id)) { + // If we encounter a destructuring export like `export const [name1, name2] = [1, 2];`, + // we try collecting the identifiers from the pattern `[name1, name2]`. + identifiers.push(...getExportIdentifiersFromArrayPattern(declarationNode.id)); + } + }); + + namedExportDeclarationNodeDeclarations + .filter( + // Narrow down to class and function declarations, e.g.: + // export class Foo {}; + // export function bar() {}; + (declarationNode): declarationNode is jscsTypes.ClassDeclaration | jscsTypes.FunctionDeclaration => + ClassDeclaration.check(declarationNode) || FunctionDeclaration.check(declarationNode), + ) + .map(node => node.id) // Grab the identifier of the function/class - Note: it might be `null` when it's anonymous + .filter((id): id is jscsTypes.Identifier => Identifier.check(id)) // Elaborate way of null-checking + .forEach(id => identifiers.push(id.name)); // Collect the name of the identifier + + ast + .find(ExportSpecifier) // Find stuff like `export {} [from ...];` + .nodes() + .forEach(specifier => { + // Taking the example above `specifier.exported.name` always contains `id` unless `name` is specified, then it's `name`; + if (specifier.exported.name !== 'default') { + // You can do default exports "export { something as default };" but we do not want to collect "default" in this + // function since it only wants to collect named exports. + identifiers.push(specifier.exported.name); + } + }); + + ast + .find(ExportAllDeclaration) // Find stuff like `export * from ..." and "export * as someVariable from ...` + .nodes() + .forEach(declaration => { + // Narrow it down to only find `export * as someVariable from ...` (emphasis on "as someVariable") + if (declaration.exported) { + identifiers.push(declaration.exported.name); // `declaration.exported.name` contains "someVariable" + } + }); + + return [...new Set(identifiers)]; // dedupe +} diff --git a/packages/nextjs/src/config/loaders/dataFetchersLoader.ts b/packages/nextjs/src/config/loaders/dataFetchersLoader.ts index 2758a0f49807..d94811498e79 100644 --- a/packages/nextjs/src/config/loaders/dataFetchersLoader.ts +++ b/packages/nextjs/src/config/loaders/dataFetchersLoader.ts @@ -6,15 +6,26 @@ * manipulating them, and then turning them back into strings and appending our template code to the user's (modified) * page code. Greater detail and explanations can be found in situ in the functions below and in the helper functions in * `ast.ts`. + * + * For `getInitialProps` we create a virtual proxy-module that re-exports all the exports and default exports of the + * original file and wraps `getInitialProps`. We do this since it allows us to very generically wrap `getInitialProps` + * for all kinds ways users might define default exports (which are a lot of ways). */ - import { logger } from '@sentry/utils'; import * as fs from 'fs'; import * as path from 'path'; import { isESM } from '../../utils/isESM'; import type { AST } from './ast'; -import { findDeclarations, findExports, makeAST, removeComments, renameIdentifiers } from './ast'; +import { + findDeclarations, + findExports, + getExportIdentifierNames, + hasDefaultExport, + makeAST, + removeComments, + renameIdentifiers, +} from './ast'; import type { LoaderThis } from './types'; // Map to keep track of each function's placeholder in the template and what it should be replaced with. (The latter @@ -94,44 +105,81 @@ function wrapFunctions(userCode: string, templateCode: string, filepath: string) * Wrap `getStaticPaths`, `getStaticProps`, and `getServerSideProps` (if they exist) in the given page code */ export default function wrapDataFetchersLoader(this: LoaderThis, userCode: string): string { - // We know one or the other will be defined, depending on the version of webpack being used - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { projectDir } = this.getOptions ? this.getOptions() : this.query!; - // For now this loader only works for ESM code if (!isESM(userCode)) { return userCode; } - // If none of the functions we want to wrap appears in the page's code, there's nothing to do. (Note: We do this as a - // simple substring match (rather than waiting until we've parsed the code) because it's meant to be an - // as-fast-as-possible fail-fast. It's possible for user code to pass this check, even if it contains none of the - // functions in question, just by virtue of the correct string having been found, be it in a comment, as part of a - // longer variable name, etc. That said, when we actually do the code manipulation we'll be working on the code's AST, - // meaning we'll be able to differentiate between code we actually want to change and any false positives which might - // come up here.) - if (Object.keys(DATA_FETCHING_FUNCTIONS).every(functionName => !userCode.includes(functionName))) { - return userCode; - } + // We know one or the other will be defined, depending on the version of webpack being used + const { projectDir } = 'getOptions' in this ? this.getOptions() : this.query; - const templatePath = path.resolve(__dirname, '../templates/dataFetchersLoaderTemplate.js'); - // make sure the template is included when runing `webpack watch` - this.addDependency(templatePath); + // In the following branch we will proxy the user's file. This means we return code (basically an entirely new file) + // that re - exports all the user file's originial export, but with a "sentry-proxy-loader" query in the module + // string. + // This looks like the following: `export { a, b, c } from "[imagine userfile path here]?sentry-proxy-loader";` + // Additionally, in this proxy file we import the userfile's default export, wrap `getInitialProps` on that default + // export, and re -export the now modified default export as default. + // Webpack will resolve the module with the "sentry-proxy-loader" query to the original file, but will give us access + // to the query via`this.resourceQuery`. If we see that `this.resourceQuery` includes includes "sentry-proxy-loader" + // we know we're in a proxied file and do not need to proxy again. - const templateCode = fs.readFileSync(templatePath).toString(); + if (!this.resourceQuery.includes('sentry-proxy-loader')) { + const ast = makeAST(userCode, true); // is there a reason to ever parse without typescript? - const [modifiedUserCode, modifiedTemplateCode] = wrapFunctions( - userCode, - templateCode, - // Relative path to the page we're currently processing, for use in error messages - path.relative(projectDir, this.resourcePath), - ); + const exportedIdentifiers = getExportIdentifierNames(ast); - // Fill in template placeholders - let injectedCode = modifiedTemplateCode; - for (const { placeholder, alias } of Object.values(DATA_FETCHING_FUNCTIONS)) { - injectedCode = injectedCode.replace(placeholder, alias); - } + let outputFileContent = ''; + + if (exportedIdentifiers.length > 0) { + outputFileContent += `export { ${exportedIdentifiers.join(', ')} } from "${ + this.resourcePath + }?sentry-proxy-loader";`; + } + + if (hasDefaultExport(ast)) { + outputFileContent += ` + import { default as _sentry_default } from "${this.resourcePath}?sentry-proxy-loader"; + import { withSentryGetInitialProps } from "@sentry/nextjs"; - return `${modifiedUserCode}\n${injectedCode}`; + if (typeof _sentry_default.getInitialProps === 'function') { + _sentry_default.getInitialProps = withSentryGetInitialProps(_sentry_default.getInitialProps); + } + + export default _sentry_default;`; + } + + return outputFileContent; + } else { + // If none of the functions we want to wrap appears in the page's code, there's nothing to do. (Note: We do this as a + // simple substring match (rather than waiting until we've parsed the code) because it's meant to be an + // as-fast-as-possible fail-fast. It's possible for user code to pass this check, even if it contains none of the + // functions in question, just by virtue of the correct string having been found, be it in a comment, as part of a + // longer variable name, etc. That said, when we actually do the code manipulation we'll be working on the code's AST, + // meaning we'll be able to differentiate between code we actually want to change and any false positives which might + // come up here.) + if (Object.keys(DATA_FETCHING_FUNCTIONS).every(functionName => !userCode.includes(functionName))) { + return userCode; + } + + const templatePath = path.resolve(__dirname, '../templates/dataFetchersLoaderTemplate.js'); + // make sure the template is included when runing `webpack watch` + this.addDependency(templatePath); + + const templateCode = fs.readFileSync(templatePath).toString(); + + const [modifiedUserCode, modifiedTemplateCode] = wrapFunctions( + userCode, + templateCode, + // Relative path to the page we're currently processing, for use in error messages + path.relative(projectDir, this.resourcePath), + ); + + // Fill in template placeholders + let injectedCode = modifiedTemplateCode; + for (const { placeholder, alias } of Object.values(DATA_FETCHING_FUNCTIONS)) { + injectedCode = injectedCode.replace(new RegExp(placeholder, 'g'), alias); + } + + return `${modifiedUserCode}\n${injectedCode}`; + } } diff --git a/packages/nextjs/src/config/loaders/prefixLoader.ts b/packages/nextjs/src/config/loaders/prefixLoader.ts index 668ca194dfbb..6422941c99e5 100644 --- a/packages/nextjs/src/config/loaders/prefixLoader.ts +++ b/packages/nextjs/src/config/loaders/prefixLoader.ts @@ -12,8 +12,7 @@ type LoaderOptions = { */ export default function prefixLoader(this: LoaderThis, userCode: string): string { // We know one or the other will be defined, depending on the version of webpack being used - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { distDir } = this.getOptions ? this.getOptions() : this.query!; + const { distDir } = 'getOptions' in this ? this.getOptions() : this.query; const templatePath = path.resolve(__dirname, '../templates/prefixLoaderTemplate.js'); // make sure the template is included when runing `webpack watch` diff --git a/packages/nextjs/src/config/loaders/types.ts b/packages/nextjs/src/config/loaders/types.ts index 17f7a737c72a..5db902abeced 100644 --- a/packages/nextjs/src/config/loaders/types.ts +++ b/packages/nextjs/src/config/loaders/types.ts @@ -1,13 +1,19 @@ -// TODO Use real webpack types export type LoaderThis = { - // Path to the file being loaded + /** Path to the file being loaded */ resourcePath: string; - // Loader options in Webpack 4 - query?: Options; - // Loader options in Webpack 5 - getOptions?: () => Options; + /** Query at the end of resolved file name ("../some-folder/some-module?foobar" -> resourceQuery: "?foobar") */ + resourceQuery: string; // Function to add outside file used by loader to `watch` process addDependency: (filepath: string) => void; -}; +} & ( + | { + // Loader options in Webpack 4 + query: Options; + } + | { + // Loader options in Webpack 5 + getOptions: () => Options; + } +); diff --git a/packages/nextjs/src/config/templates/dataFetchersLoaderTemplate.ts b/packages/nextjs/src/config/templates/dataFetchersLoaderTemplate.ts index b3fc03c45091..3d3d26994307 100644 --- a/packages/nextjs/src/config/templates/dataFetchersLoaderTemplate.ts +++ b/packages/nextjs/src/config/templates/dataFetchersLoaderTemplate.ts @@ -23,12 +23,14 @@ declare const __ORIG_GSPATHS__: GetStaticPathsFunction; import * as ServerSideSentryNextjsSDK from '@sentry/nextjs'; export const getServerSideProps = - typeof __ORIG_GSSP__ === 'function' ? ServerSideSentryNextjsSDK.withSentryGSSP(__ORIG_GSSP__) : __ORIG_GSSP__; + typeof __ORIG_GSSP__ === 'function' + ? ServerSideSentryNextjsSDK.withSentryGetServerSideProps(__ORIG_GSSP__) + : __ORIG_GSSP__; export const getStaticProps = typeof __ORIG_GSPROPS__ === 'function' - ? ServerSideSentryNextjsSDK.withSentryGSProps(__ORIG_GSPROPS__) + ? ServerSideSentryNextjsSDK.withSentryGetStaticProps(__ORIG_GSPROPS__) : __ORIG_GSPROPS__; export const getStaticPaths = typeof __ORIG_GSPATHS__ === 'function' - ? ServerSideSentryNextjsSDK.withSentryGSPaths(__ORIG_GSPATHS__) + ? ServerSideSentryNextjsSDK.withSentryGetStaticPaths(__ORIG_GSPATHS__) : __ORIG_GSPATHS__; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 4b750fbe1c6e..a636609a9a7b 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -43,7 +43,10 @@ export function constructWebpackConfigFunction( // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that // `incomingConfig` and `buildContext` are referred to as `config` and `options` in the nextjs docs. - const newWebpackFunction = (incomingConfig: WebpackConfigObject, buildContext: BuildContext): WebpackConfigObject => { + return function newWebpackFunction( + incomingConfig: WebpackConfigObject, + buildContext: BuildContext, + ): WebpackConfigObject { const { isServer, dev: isDev, dir: projectDir } = buildContext; let newConfig = { ...incomingConfig }; @@ -165,8 +168,6 @@ export function constructWebpackConfigFunction( return newConfig; }; - - return newWebpackFunction; } /** diff --git a/packages/nextjs/src/config/wrappers/index.ts b/packages/nextjs/src/config/wrappers/index.ts index ee3fdcdd8f2f..6ef54a3001a3 100644 --- a/packages/nextjs/src/config/wrappers/index.ts +++ b/packages/nextjs/src/config/wrappers/index.ts @@ -1,3 +1,4 @@ -export { withSentryGSPaths } from './withSentryGSPaths'; -export { withSentryGSProps } from './withSentryGSProps'; -export { withSentryGSSP } from './withSentryGSSP'; +export { withSentryGetStaticPaths } from './withSentryGetStaticPaths'; +export { withSentryGetStaticProps } from './withSentryGetStaticProps'; +export { withSentryGetInitialProps } from './withSentryGetInitialProps'; +export { withSentryGetServerSideProps } from './withSentryGetServerSideProps'; diff --git a/packages/nextjs/src/config/wrappers/types.ts b/packages/nextjs/src/config/wrappers/types.ts index 5587aa1a71f7..f407ae72c53d 100644 --- a/packages/nextjs/src/config/wrappers/types.ts +++ b/packages/nextjs/src/config/wrappers/types.ts @@ -8,6 +8,8 @@ import type { GetStaticProps, GetStaticPropsContext, GetStaticPropsResult, + NextPage, + NextPageContext, } from 'next'; type Paths = { [key: string]: string | string[] }; @@ -34,4 +36,11 @@ export type GSSP = { result: GetServerSidePropsResult; }; -export type DataFetchingFunction = GSPaths | GSProps | GSSP; +export type GIProps = { + fn: Required['getInitialProps']; + wrappedFn: NextPage['getInitialProps']; + context: NextPageContext; + result: unknown; +}; + +export type DataFetchingFunction = GSPaths | GSProps | GSSP | GIProps; diff --git a/packages/nextjs/src/config/wrappers/withSentryGSPaths.ts b/packages/nextjs/src/config/wrappers/withSentryGSPaths.ts deleted file mode 100644 index 7161f81f9780..000000000000 --- a/packages/nextjs/src/config/wrappers/withSentryGSPaths.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GSPaths } from './types'; -import { callOriginal } from './wrapperUtils'; - -/** - * Create a wrapped version of the user's exported `getStaticPaths` function - * - * @param origGSPaths: The user's `getStaticPaths` function - * @returns A wrapped version of the function - */ -export function withSentryGSPaths(origGSPaths: GSPaths['fn']): GSPaths['wrappedFn'] { - const wrappedGSPaths = async function (context: GSPaths['context']): Promise { - return callOriginal(origGSPaths, context); - }; - - return wrappedGSPaths; -} diff --git a/packages/nextjs/src/config/wrappers/withSentryGSProps.ts b/packages/nextjs/src/config/wrappers/withSentryGSProps.ts deleted file mode 100644 index 7121de35f28b..000000000000 --- a/packages/nextjs/src/config/wrappers/withSentryGSProps.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { GSProps } from './types'; -import { callOriginal } from './wrapperUtils'; - -/** - * Create a wrapped version of the user's exported `getStaticProps` function - * - * @param origGSProps: The user's `getStaticProps` function - * @returns A wrapped version of the function - */ -export function withSentryGSProps(origGSProps: GSProps['fn']): GSProps['wrappedFn'] { - const wrappedGSProps = async function (context: GSProps['context']): Promise { - return callOriginal(origGSProps, context); - }; - - return wrappedGSProps; -} diff --git a/packages/nextjs/src/config/wrappers/withSentryGSSP.ts b/packages/nextjs/src/config/wrappers/withSentryGSSP.ts deleted file mode 100644 index ddcc8f8035ca..000000000000 --- a/packages/nextjs/src/config/wrappers/withSentryGSSP.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { GSSP } from './types'; -import { callOriginal } from './wrapperUtils'; - -/** - * Create a wrapped version of the user's exported `getServerSideProps` function - * - * @param origGSSP: The user's `getServerSideProps` function - * @returns A wrapped version of the function - */ -export function withSentryGSSP(origGSSP: GSSP['fn']): GSSP['wrappedFn'] { - const wrappedGSSP = async function (context: GSSP['context']): Promise { - return callOriginal(origGSSP, context); - }; - - return wrappedGSSP; -} diff --git a/packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts b/packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts new file mode 100644 index 000000000000..7e5e209ae98a --- /dev/null +++ b/packages/nextjs/src/config/wrappers/withSentryGetInitialProps.ts @@ -0,0 +1,14 @@ +import { GIProps } from './types'; + +/** + * Create a wrapped version of the user's exported `getInitialProps` function + * + * @param origGIProps: The user's `getInitialProps` function + * @param origGIPropsHost: The user's object on which `getInitialProps` lives (used for `this`) + * @returns A wrapped version of the function + */ +export function withSentryGetInitialProps(origGIProps: GIProps['fn']): GIProps['wrappedFn'] { + return async function (this: unknown, ...args: Parameters) { + return await origGIProps.call(this, ...args); + }; +} diff --git a/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts b/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts new file mode 100644 index 000000000000..0293ad999c45 --- /dev/null +++ b/packages/nextjs/src/config/wrappers/withSentryGetServerSideProps.ts @@ -0,0 +1,14 @@ +import { GSSP } from './types'; +import { callOriginal } from './wrapperUtils'; + +/** + * Create a wrapped version of the user's exported `getServerSideProps` function + * + * @param origGetServerSideProps: The user's `getServerSideProps` function + * @returns A wrapped version of the function + */ +export function withSentryGetServerSideProps(origGetServerSideProps: GSSP['fn']): GSSP['wrappedFn'] { + return async function (context: GSSP['context']): Promise { + return callOriginal(origGetServerSideProps, context); + }; +} diff --git a/packages/nextjs/src/config/wrappers/withSentryGetStaticPaths.ts b/packages/nextjs/src/config/wrappers/withSentryGetStaticPaths.ts new file mode 100644 index 000000000000..0c821c2940f2 --- /dev/null +++ b/packages/nextjs/src/config/wrappers/withSentryGetStaticPaths.ts @@ -0,0 +1,14 @@ +import type { GSPaths } from './types'; +import { callOriginal } from './wrapperUtils'; + +/** + * Create a wrapped version of the user's exported `getStaticPaths` function + * + * @param origGetStaticPaths: The user's `getStaticPaths` function + * @returns A wrapped version of the function + */ +export function withSentryGetStaticPaths(origGetStaticPaths: GSPaths['fn']): GSPaths['wrappedFn'] { + return async function (context: GSPaths['context']): Promise { + return callOriginal(origGetStaticPaths, context); + }; +} diff --git a/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts b/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts new file mode 100644 index 000000000000..23d6023a25bf --- /dev/null +++ b/packages/nextjs/src/config/wrappers/withSentryGetStaticProps.ts @@ -0,0 +1,14 @@ +import { GSProps } from './types'; +import { callOriginal } from './wrapperUtils'; + +/** + * Create a wrapped version of the user's exported `getStaticProps` function + * + * @param origGetStaticProps: The user's `getStaticProps` function + * @returns A wrapped version of the function + */ +export function withSentryGetStaticProps(origGetStaticProps: GSProps['fn']): GSProps['wrappedFn'] { + return async function (context: GSProps['context']): Promise { + return callOriginal(origGetStaticProps, context); + }; +} diff --git a/packages/nextjs/src/index.client.ts b/packages/nextjs/src/index.client.ts index a7bfca1f2660..1ff546c5248d 100644 --- a/packages/nextjs/src/index.client.ts +++ b/packages/nextjs/src/index.client.ts @@ -11,6 +11,8 @@ export * from '@sentry/react'; export { nextRouterInstrumentation } from './performance/client'; export { captureUnderscoreErrorException } from './utils/_error'; +export { withSentryGetInitialProps } from './config/wrappers'; + export { Integrations }; // Previously we expected users to import `BrowserTracing` like this: diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index ef398eb49d8f..914fe26168ce 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -125,7 +125,12 @@ function addServerIntegrations(options: NextjsOptions): void { export type { SentryWebpackPluginOptions } from './config/types'; export { withSentryConfig } from './config'; export { isBuild } from './utils/isBuild'; -export { withSentryGSProps, withSentryGSSP, withSentryGSPaths } from './config/wrappers'; +export { + withSentryGetServerSideProps, + withSentryGetStaticProps, + withSentryGetStaticPaths, + withSentryGetInitialProps, +} from './config/wrappers'; export { withSentry } from './utils/withSentry'; // Wrap various server methods to enable error monitoring and tracing. (Note: This only happens for non-Vercel diff --git a/packages/nextjs/test/config/ast.test.ts b/packages/nextjs/test/config/ast.test.ts new file mode 100644 index 000000000000..c6188f49d2ac --- /dev/null +++ b/packages/nextjs/test/config/ast.test.ts @@ -0,0 +1,100 @@ +import { getExportIdentifierNames, hasDefaultExport, makeAST } from '../../src/config/loaders/ast'; + +test.each([ + // examples taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export + // Exporting declarations + ['export let name1, name2; export var name3, name4;', false], + ['export const name1 = 1, name2 = 2;', false], + ['export var name1 = 1, name2 = 2;', false], + ['export let name1 = 1, name2 = 2;', false], + ['export function functionName() {}', false], + ['export class ClassName {}', false], + ['export function* generatorFunctionName() {}', false], + ['export const { name1, bar: name2, someValue: { someNestedValue: name3 }, ...name4 } = {};', false], + ['export const [ name1, name2, ...name3 ] = [1, 2, 3, 4];', false], + ['export const { foo: { bar: [{ baz: [name1, ...name2], ...name3 }, name4, name5]} } = {};', false], + ['export const [{ a: { ...name1 }, b: [,name2] }, name3] = [];', false], + // Export list + ['var name1, name2, name3; export { name1, name2, name3 };', false], + ['var variable1, variable2, name3; export { variable1 as name1, variable2 as name2, name3 };', false], + ['var name1, name2, name3; export { name1 as default, name1, name2 };', true], + // Default exports + ['export default 1;', true], + ['export default function functionName() {}', true], + ['export default class ClassName {}', true], + ['export default function* generatorFunctionName() {}', true], + ['export default function () {}', true], + ['export default class {}', true], + ['export default function* () {}', true], + ['const someObj = { a: { b: 1 }}; export default a.b', true], + // Aggregating modules + ['export * from "module-name";', false], + ['export * as name1 from "module-name";', false], + ['export { name1, name2 } from "module-name";', false], + ['export { import1 as name1, import2 as name2, name3 } from "module-name";', false], + ['export { default } from "module-name";', true], + ['export { default, name1 } from "module-name";', true], +])('hasDefaultExport(%s) should return %p', (program, expectedResult) => { + const ast = makeAST(program, true); + expect(hasDefaultExport(ast)).toBe(expectedResult); +}); + +test.each([ + // examples taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export + // Exporting declarations + ['export let name1, name2; export var name3, name4;', ['name1', 'name2', 'name3', 'name4']], + ['export const name1 = 1, name2 = 2;', ['name1', 'name2']], + ['export var name1 = 1, name2 = 2;', ['name1', 'name2']], + ['export let name1 = 1, name2 = 2;', ['name1', 'name2']], + ['export function functionName() {}', ['functionName']], + ['export class ClassName {}', ['ClassName']], + ['export function* generatorFunctionName() {}', ['generatorFunctionName']], + [ + 'export const { name1, bar: name2, someValue: { someNestedValue: name3 }, ...name4 } = {};', + ['name1', 'name2', 'name3', 'name4'], + ], + ['export const [ name1, name2, ...name3 ] = [1, 2, 3, 4];', ['name1', 'name2', 'name3']], + [ + 'export const { foo: { bar: [{ baz: [name1, ...name2], ...name3 }, name4, name5]} } = {};', + ['name1', 'name2', 'name3', 'name4', 'name5'], + ], + ['export const [{ a: { ...name1 }, b: [,name2] }, name3] = [];', ['name1', 'name2', 'name3']], + // Export list + [ + ` + var name1, name2, name3; + export { name1, name2, name3 };`, + ['name1', 'name2', 'name3'], + ], + [ + ` + var variable1, variable2, name3; + export { variable1 as name1, variable2 as name2, name3 };`, + ['name1', 'name2', 'name3'], + ], + [ + ` + var name1, name2, name3; + export { name1 as default, name1, name2 };`, + ['name1', 'name2'], + ], + // Default exports + ['export default 1;', []], + ['export default function functionName() {}', []], + ['export default class ClassName {}', []], + ['export default function* generatorFunctionName() {}', []], + ['export default function () {}', []], + ['export default class {}', []], + ['export default function* () {}', []], + ['const someObj = { a: { b: 1 }}; export default a.b', []], + // Aggregating modules + ['export * from "module-name";', []], + ['export * as name1 from "module-name";', ['name1']], + ['export { name1, name2 } from "module-name";', ['name1', 'name2']], + ['export { import1 as name1, import2 as name2, name3 } from "module-name";', ['name1', 'name2', 'name3']], + ['export { default } from "module-name";', []], + ['export { default, name1 } from "module-name";', ['name1']], +])('getExportIdentifiers(%s) should return %p', (program, expectedIdentifiers) => { + const ast = makeAST(program, true); + expect(getExportIdentifierNames(ast)).toStrictEqual(expectedIdentifiers); +});