-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
ref(nextjs): Wrap server-side data-fetching methods during build #5503
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
070358e
add `jscodeshift` and `@babel/parser`
lobsterkatie 05f9d1f
add jsx and tsx parsers
lobsterkatie 578e7c9
add AST helper functions
lobsterkatie 8fb2c4b
add `isESM` helper
lobsterkatie 84aa63b
add template for wrapping data-fetching functions
lobsterkatie 93d6ffd
add loader for wrapping data-fetching functions
lobsterkatie 0dabf8d
add and export pass-through wrapper functions
lobsterkatie 565f259
use loader in webpack config
lobsterkatie cf0056e
switch to using `export default` for loaders
lobsterkatie d9e4118
add flag to enable auto-wrapping
lobsterkatie File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
137 changes: 137 additions & 0 deletions
137
packages/nextjs/src/config/loaders/dataFetchersLoader.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
/** | ||
* This loader auto-wraps a user's page-level data-fetching functions (`getStaticPaths`, `getStaticProps`, and | ||
* `getServerSideProps`) in order to instrument them for tracing. At a high level, this is done by finding the relevant | ||
* functions, renaming them so as not to create a name collision, and then creating a new version of each function which | ||
* is a wrapped version of the original. We do this by parsing the user's code and some template code into ASTs, | ||
* 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`. | ||
*/ | ||
|
||
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 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 | ||
// will get added as we process the user code. Setting it to an empty string here means TS won't complain when we set it | ||
// to a non-empty string later.) | ||
const DATA_FETCHING_FUNCTIONS = { | ||
getServerSideProps: { placeholder: '__ORIG_GSSP__', alias: '' }, | ||
getStaticProps: { placeholder: '__ORIG_GSPROPS__', alias: '' }, | ||
getStaticPaths: { placeholder: '__ORIG_GSPATHS__', alias: '' }, | ||
}; | ||
|
||
type LoaderOptions = { | ||
projectDir: string; | ||
}; | ||
|
||
/** | ||
* Find any data-fetching functions the user's code contains and rename them to prevent clashes, then whittle the | ||
* template exporting wrapped versions instead down to only the functions found. | ||
* | ||
* @param userCode The source code of the current page file | ||
* @param templateCode The source code of the full template, including all functions | ||
* @param filepath The path to the current pagefile, within the project directory | ||
* @returns A tuple of modified user and template code | ||
*/ | ||
function wrapFunctions(userCode: string, templateCode: string, filepath: string): string[] { | ||
let userAST: AST, templateAST: AST; | ||
const isTS = new RegExp('\\.tsx?$').test(filepath); | ||
lobsterkatie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
try { | ||
userAST = makeAST(userCode, isTS); | ||
templateAST = makeAST(templateCode, false); | ||
} catch (err) { | ||
logger.warn(`Couldn't add Sentry to ${filepath} because there was a parsing error: ${err}`); | ||
// Replace the template code with an empty string, so in the end the user code is untouched | ||
return [userCode, '']; | ||
} | ||
|
||
// Comments are useful to have in the template for anyone reading it, but don't make sense to be injected into user | ||
// code, because they're about the template-i-ness of the template, not the code itself | ||
// TODO: Move this to our rollup build | ||
removeComments(templateAST); | ||
|
||
for (const functionName of Object.keys(DATA_FETCHING_FUNCTIONS)) { | ||
// Find and rename all identifiers whose name is `functionName` | ||
const alias = renameIdentifiers(userAST, functionName); | ||
|
||
// `alias` will be defined iff the user code contains the function in question and renaming has been done | ||
if (alias) { | ||
// We keep track of the alias for each function, so that later on we can fill it in for the placeholder in the | ||
// template. (Not doing that now because it's much more easily done once the template code has gone back to being | ||
// a string.) | ||
DATA_FETCHING_FUNCTIONS[functionName as keyof typeof DATA_FETCHING_FUNCTIONS].alias = alias; | ||
} | ||
|
||
// Otherwise, if the current function doesn't exist anywhere in the user's code, delete the code in the template | ||
// wrapping that function | ||
// | ||
// Note: We start with all of the possible wrapper lines in the template and delete the ones we don't need (rather | ||
// than starting with none and adding in the ones we do need) because it allows them to live in our souce code as | ||
// *code*. If we added them in, they'd have to be strings containing code, and we'd lose all of the benefits of | ||
// syntax highlighting, linting, etc. | ||
lobsterkatie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
else { | ||
// We have to look for declarations and exports separately because when we build the SDK, Rollup turns | ||
// export const XXX = ... | ||
// into | ||
// const XXX = ... | ||
// export { XXX } | ||
findExports(templateAST, functionName).remove(); | ||
findDeclarations(templateAST, functionName).remove(); | ||
} | ||
} | ||
|
||
return [userAST.toSource(), templateAST.toSource()]; | ||
} | ||
|
||
/** | ||
* Wrap `getStaticPaths`, `getStaticProps`, and `getServerSideProps` (if they exist) in the given page code | ||
*/ | ||
export default function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>, 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; | ||
lobsterkatie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// 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(placeholder, alias); | ||
} | ||
lobsterkatie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return `${modifiedUserCode}\n${injectedCode}`; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { default as prefixLoader } from './prefixLoader'; | ||
export { default as dataFetchersLoader } from './dataFetchersLoader'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
/** | ||
* Note: The implementation here is loosely based on the jsx and tsx parsers in 'jscodeshift'. It doesn't expose its | ||
* parsers, so we have to provide our own if we want to use anything besides the default. Fortunately, its parsers turn | ||
* out to just be wrappers around `babel.parse` with certain options set. The options chosen here are different from the | ||
* `jscodeshift` parsers in that a) unrecognized and deprecated options and options set to default values have been | ||
* removed, and b) all standard plugins are included, meaning the widest range of user code is able to be parsed. | ||
*/ | ||
|
||
import * as babel from '@babel/parser'; | ||
import { File } from '@babel/types'; | ||
|
||
type Parser = { | ||
parse: (code: string) => babel.ParseResult<File>; | ||
}; | ||
|
||
const jsxOptions: babel.ParserOptions = { | ||
// Nextjs supports dynamic import, so this seems like a good idea | ||
allowImportExportEverywhere: true, | ||
// We're only supporting wrapping in ESM pages | ||
sourceType: 'module', | ||
// Without `tokens`, jsx parsing breaks | ||
tokens: true, | ||
// The maximal set of non-mutually-exclusive standard plugins, so as to support as much weird syntax in our users' | ||
// code as possible | ||
plugins: [ | ||
'asyncDoExpressions', | ||
'decimal', | ||
['decorators', { decoratorsBeforeExport: false }], | ||
'decoratorAutoAccessors', | ||
'destructuringPrivate', | ||
'doExpressions', | ||
'estree', | ||
'exportDefaultFrom', | ||
'functionBind', | ||
'importMeta', | ||
'importAssertions', | ||
'jsx', | ||
'moduleBlocks', | ||
'partialApplication', | ||
['pipelineOperator', { proposal: 'hack', topicToken: '^' }], | ||
'regexpUnicodeSets', | ||
'throwExpressions', | ||
] as babel.ParserPlugin[], | ||
}; | ||
|
||
const tsxOptions = { | ||
...jsxOptions, | ||
// Because `jsxOptions` is typed as a `ParserOptions` object, TS doesn't discount the possibility of its `plugins` | ||
// property being undefined, even though it is, in fact, very clearly defined - hence the empty array. | ||
plugins: [...(jsxOptions.plugins || []), 'typescript'] as babel.ParserPlugin[], | ||
}; | ||
|
||
/** | ||
* Create either a jsx or tsx parser to be used by `jscodeshift`. | ||
* | ||
* @param type Either 'jsx' or 'tsx' | ||
* @returns An object with the appropriate `parse` method. | ||
*/ | ||
export function makeParser(type: 'jsx' | 'tsx'): Parser { | ||
lobsterkatie marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const options = type === 'jsx' ? jsxOptions : tsxOptions; | ||
return { | ||
parse: code => babel.parse(code, options), | ||
}; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
packages/nextjs/src/config/templates/dataFetchersLoaderTemplate.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import type { | ||
GetServerSideProps as GetServerSidePropsFunction, | ||
GetStaticPaths as GetStaticPathsFunction, | ||
GetStaticProps as GetStaticPropsFunction, | ||
} from 'next'; | ||
|
||
declare const __ORIG_GSSP__: GetServerSidePropsFunction; | ||
declare const __ORIG_GSPROPS__: GetStaticPropsFunction; | ||
declare const __ORIG_GSPATHS__: GetStaticPathsFunction; | ||
|
||
// We import the SDK under a purposefully clunky name, to lessen to near zero the chances of a name collision in case | ||
// the user has also imported Sentry for some reason. (In the future, we could check for such a collision using the AST, | ||
// but this is a lot simpler.) | ||
// | ||
// TODO: This import line is here because it needs to be in the injected code, but it also would (ideally) | ||
// let us take advantage of typechecking, via the linter (both eslint and the TS linter), using intellisense, and when | ||
// building. Solving for all five simultaneously seems to be tricky, however, because of the circular dependency. This | ||
// is one of a number of possible compromise options, which seems to hit everything except eslint linting and | ||
// typechecking via `tsc`. (TS linting and intellisense both work, though, so we do get at least some type safety.) See | ||
// https://github.com/getsentry/sentry-javascript/pull/5503#discussion_r936827996 for more details. | ||
// | ||
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved | ||
import * as ServerSideSentryNextjsSDK from '@sentry/nextjs'; | ||
|
||
export const getServerSideProps = | ||
typeof __ORIG_GSSP__ === 'function' ? ServerSideSentryNextjsSDK.withSentryGSSP(__ORIG_GSSP__) : __ORIG_GSSP__; | ||
export const getStaticProps = | ||
typeof __ORIG_GSPROPS__ === 'function' | ||
? ServerSideSentryNextjsSDK.withSentryGSProps(__ORIG_GSPROPS__) | ||
: __ORIG_GSPROPS__; | ||
export const getStaticPaths = | ||
typeof __ORIG_GSPATHS__ === 'function' | ||
? ServerSideSentryNextjsSDK.withSentryGSPaths(__ORIG_GSPATHS__) | ||
: __ORIG_GSPATHS__; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export { withSentryGSPaths } from './withSentryGSPaths'; | ||
export { withSentryGSProps } from './withSentryGSProps'; | ||
export { withSentryGSSP } from './withSentryGSSP'; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.