Skip to content

Commit 93d6ffd

Browse files
committed
add loader for wrapping data-fetching functions
1 parent 84aa63b commit 93d6ffd

File tree

4 files changed

+146
-5
lines changed

4 files changed

+146
-5
lines changed

packages/nextjs/rollup.npm.config.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,12 @@ export default [
3636
),
3737
...makeNPMConfigVariants(
3838
makeBaseNPMConfig({
39-
entrypoints: ['src/config/loaders/prefixLoader.ts'],
39+
entrypoints: ['src/config/loaders/index.ts'],
4040

4141
packageSpecificConfig: {
4242
output: {
4343
// make it so Rollup calms down about the fact that we're doing `export { loader as default }`
44-
exports: 'default',
45-
46-
// preserve the original file structure (i.e., so that everything is still relative to `src`)
47-
entryFileNames: 'config/loaders/[name].js',
44+
exports: 'named',
4845
},
4946
},
5047
}),
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* This loader auto-wraps a user's page-level data-fetching functions (`getStaticPaths`, `getStaticProps`, and
3+
* `getServerSideProps`) in order to instrument them for tracing. At a high level, this is done by finding the relevant
4+
* functions, renaming them so as not to create a name collision, and then creating a new version of each function which
5+
* is a wrapped version of the original. We do this by parsing the user's code and some template code into ASTs,
6+
* manipulating them, and then turning them back into strings and appending our template code to the user's (modified)
7+
* page code. Greater detail and explanations can be found in situ in the functions below and in the helper functions in
8+
* `ast.ts`.
9+
*/
10+
11+
import { logger } from '@sentry/utils';
12+
import * as fs from 'fs';
13+
import * as path from 'path';
14+
15+
import { isESM } from '../../utils/isESM';
16+
import type { AST } from './ast';
17+
import { findDeclarations, findExports, makeAST, removeComments, renameIdentifiers } from './ast';
18+
import type { LoaderThis } from './types';
19+
20+
// Map to keep track of each function's placeholder in the template and what it should be replaced with. (The latter
21+
// 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
22+
// to a non-empty string later.)
23+
const DATA_FETCHING_FUNCTIONS = {
24+
getServerSideProps: { placeholder: '__ORIG_GSSP__', alias: '' },
25+
getStaticProps: { placeholder: '__ORIG_GSPROPS__', alias: '' },
26+
getStaticPaths: { placeholder: '__ORIG_GSPATHS__', alias: '' },
27+
};
28+
29+
type LoaderOptions = {
30+
projectDir: string;
31+
};
32+
33+
/**
34+
* Find any data-fetching functions the user's code contains and rename them to prevent clashes, then whittle the
35+
* template exporting wrapped versions instead down to only the functions found.
36+
*
37+
* @param userCode The source code of the current page file
38+
* @param templateCode The source code of the full template, including all functions
39+
* @param filepath The path to the current pagefile, within the project directory
40+
* @returns A tuple of modified user and template code
41+
*/
42+
function wrapFunctions(userCode: string, templateCode: string, filepath: string): string[] {
43+
let userAST: AST, templateAST: AST;
44+
const isTS = new RegExp('\\.tsx?$').test(filepath);
45+
46+
try {
47+
userAST = makeAST(userCode, isTS);
48+
templateAST = makeAST(templateCode, false);
49+
} catch (err) {
50+
logger.warn(`Couldn't add Sentry to ${filepath} because there was a parsing error: ${err}`);
51+
// Replace the template code with an empty string, so in the end the user code is untouched
52+
return [userCode, ''];
53+
}
54+
55+
// Comments are useful to have in the template for anyone reading it, but don't make sense to be injected into user
56+
// code, because they're about the template-i-ness of the template, not the code itself
57+
// TODO: Move this to our rollup build
58+
removeComments(templateAST);
59+
60+
for (const functionName of Object.keys(DATA_FETCHING_FUNCTIONS)) {
61+
// Find and rename all identifiers whose name is `functionName`
62+
const alias = renameIdentifiers(userAST, functionName);
63+
64+
// `alias` will be defined iff the user code contains the function in question and renaming has been done
65+
if (alias) {
66+
// We keep track of the alias for each function, so that later on we can fill it in for the placeholder in the
67+
// template. (Not doing that now because it's much more easily done once the template code has gone back to being
68+
// a string.)
69+
DATA_FETCHING_FUNCTIONS[functionName as keyof typeof DATA_FETCHING_FUNCTIONS].alias = alias;
70+
}
71+
72+
// Otherwise, if the current function doesn't exist anywhere in the user's code, delete the code in the template
73+
// wrapping that function
74+
//
75+
// Note: We start with all of the possible wrapper lines in the template and delete the ones we don't need (rather
76+
// than starting with none and adding in the ones we do need) because it allows them to live in our souce code as
77+
// *code*. If we added them in, they'd have to be strings containing code, and we'd lose all of the benefits of
78+
// syntax highlighting, linting, etc.
79+
else {
80+
// We have to look for declarations and exports separately because when we build the SDK, Rollup turns
81+
// export const XXX = ...
82+
// into
83+
// const XXX = ...
84+
// export { XXX }
85+
findExports(templateAST, functionName).remove();
86+
findDeclarations(templateAST, functionName).remove();
87+
}
88+
}
89+
90+
return [userAST.toSource(), templateAST.toSource()];
91+
}
92+
93+
/**
94+
* Wrap `getStaticPaths`, `getStaticProps`, and `getServerSideProps` (if they exist) in the given page code
95+
*/
96+
function wrapDataFetchersLoader(this: LoaderThis<LoaderOptions>, userCode: string): string {
97+
// We know one or the other will be defined, depending on the version of webpack being used
98+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
99+
const { projectDir } = this.getOptions ? this.getOptions() : this.query!;
100+
101+
// For now this loader only works for ESM code
102+
if (!isESM(userCode)) {
103+
return userCode;
104+
}
105+
106+
// 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
107+
// simple substring match (rather than waiting until we've parsed the code) because it's meant to be an
108+
// as-fast-as-possible fail-fast. It's possible for user code to pass this check, even if it contains none of the
109+
// functions in question, just by virtue of the correct string having been found, be it in a comment, as part of a
110+
// longer variable name, etc. That said, when we actually do the code manipulation we'll be working on the code's AST,
111+
// meaning we'll be able to differentiate between code we actually want to change and any false positives which might
112+
// come up here.)
113+
if (Object.keys(DATA_FETCHING_FUNCTIONS).every(functionName => !userCode.includes(functionName))) {
114+
return userCode;
115+
}
116+
117+
const templatePath = path.resolve(__dirname, '../templates/dataFetchersLoaderTemplate.js');
118+
// make sure the template is included when runing `webpack watch`
119+
this.addDependency(templatePath);
120+
121+
const templateCode = fs.readFileSync(templatePath).toString();
122+
123+
const [modifiedUserCode, modifiedTemplateCode] = wrapFunctions(
124+
userCode,
125+
templateCode,
126+
// Relative path to the page we're currently processing, for use in error messages
127+
path.relative(projectDir, this.resourcePath),
128+
);
129+
130+
// Fill in template placeholders
131+
let injectedCode = modifiedTemplateCode;
132+
for (const { placeholder, alias } of Object.values(DATA_FETCHING_FUNCTIONS)) {
133+
injectedCode = injectedCode.replace(placeholder, alias);
134+
}
135+
136+
return `${modifiedUserCode}\n${injectedCode}`;
137+
}
138+
139+
export { wrapDataFetchersLoader as default };
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as prefixLoader } from './prefixLoader';
2+
export { default as dataFetchersLoader } from './dataFetchersLoader';

packages/nextjs/src/config/loaders/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// TODO Use real webpack types
22
export type LoaderThis<Options> = {
3+
// Path to the file being loaded
4+
resourcePath: string;
5+
36
// Loader options in Webpack 4
47
query?: Options;
58
// Loader options in Webpack 5

0 commit comments

Comments
 (0)