Skip to content

Commit 0fda8e6

Browse files
committed
add loader for wrapping data-fetching functions
1 parent 0ab8f1b commit 0fda8e6

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+
import { logger } from '@sentry/utils';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
5+
import { isESM } from '../../utils/isESM';
6+
import {
7+
AST,
8+
findAvailibleAlias,
9+
findDeclarations,
10+
findExports,
11+
findIdentifiers,
12+
makeAST,
13+
removeComments,
14+
} from './ast';
15+
import { LoaderThis } from './types';
16+
17+
// Map to keep track of each function's placeholder in the template and what it should be replaced with. (The latter
18+
// 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
19+
// to a non-empty string later.)
20+
const DATA_FETCHING_FUNCTIONS = {
21+
getServerSideProps: { placeholder: '__ORIG_GSSP__', alias: '' },
22+
getStaticProps: { placeholder: '__ORIG_GSPROPS__', alias: '' },
23+
getStaticPaths: { placeholder: '__ORIG_GSPATHS__', alias: '' },
24+
};
25+
26+
type LoaderOptions = {
27+
projectDir: string;
28+
};
29+
30+
/**
31+
* Find any data-fetching functions the user's code contains and rename them to prevent clashes, then whittle the
32+
* template exporting wrapped versions instead down to only the functions found.
33+
*
34+
* @param userCode The source code of the current page file
35+
* @param templateCode The source code of the full template, including all functions
36+
* @param filepath The path to the current pagefile, within the project directory
37+
* @returns A tuple of modified user and template code
38+
*/
39+
function wrapFunctions(userCode: string, templateCode: string, filepath: string): string[] {
40+
let userAST: AST, templateAST: AST;
41+
const isTS = new RegExp('\\.tsx?$').test(filepath);
42+
43+
try {
44+
userAST = makeAST(userCode, isTS);
45+
templateAST = makeAST(templateCode, false);
46+
} catch (err) {
47+
logger.warn(`Couldn't add Sentry to ${filepath} because there was a parsing error: ${err}`);
48+
// Replace the template code with an empty string, so in the end the user code is untouched
49+
return [userCode, ''];
50+
}
51+
52+
// Comments are useful to have in the template for anyone reading it, but don't make sense to be injected into user
53+
// code, because they're about the template-i-ness of the template, not the code itself
54+
removeComments(templateAST);
55+
56+
for (const functionName of Object.keys(DATA_FETCHING_FUNCTIONS)) {
57+
const matchingNodes = findIdentifiers(userAST, functionName);
58+
59+
// If the current function exists in a user's code, prefix all references to it with an underscore (or a few
60+
// underscores, if that's what it takes to avoid a name collision), so as not to conflict with the wrapped version
61+
// we're going to create.
62+
if (matchingNodes.length > 0) {
63+
const functionAlias = findAvailibleAlias(userAST, functionName);
64+
matchingNodes.forEach(nodePath => (nodePath.node.name = functionAlias));
65+
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 = functionAlias;
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+
// TODO: Can you even write nextjs pages in CJS?
103+
if (!isESM(userCode)) {
104+
return userCode;
105+
}
106+
107+
// 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
108+
// simple substring match (rather than waiting until we've parsed the code) because it's meant to be an
109+
// as-fast-as-possible fail-fast. It's possible for user code to pass this check, even if it contains none of the
110+
// functions in question, just by virtue of the correct string having been found, be it in a comment, as part of a
111+
// longer variable name, etc. That said, when we actually do the code manipulation we'll be working on the code's AST,
112+
// meaning we'll be able to differentiate between code we actually want to change and any false positives which might
113+
// come up here.)
114+
if (Object.keys(DATA_FETCHING_FUNCTIONS).every(functionName => !userCode.includes(functionName))) {
115+
return userCode;
116+
}
117+
118+
const templatePath = path.resolve(__dirname, '../templates/dataFetchersLoaderTemplate.js');
119+
// make sure the template is included when runing `webpack watch`
120+
this.addDependency(templatePath);
121+
122+
const templateCode = fs.readFileSync(templatePath).toString();
123+
124+
const [modifiedUserCode, modifiedTemplateCode] = wrapFunctions(
125+
userCode,
126+
templateCode,
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)