Skip to content

Commit 2e07b78

Browse files
lforstlobsterkatie
andauthored
feat(nextjs): Wrap server-side getInitialProps (#5546)
Co-authored-by: Katie Byers <[email protected]>
1 parent 9b7f432 commit 2e07b78

18 files changed

+490
-102
lines changed

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

+209-2
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,24 @@ const jscs = jscodeshiftDefault || jscodeshiftNamespace;
2525

2626
// These are types not in the TS sense, but in the instance-of-a-Type-class sense
2727
const {
28+
ArrayPattern,
29+
ClassDeclaration,
30+
ExportAllDeclaration,
31+
ExportDefaultDeclaration,
32+
ExportDefaultSpecifier,
33+
ExportNamedDeclaration,
2834
ExportSpecifier,
35+
FunctionDeclaration,
2936
Identifier,
3037
ImportSpecifier,
38+
JSXIdentifier,
3139
MemberExpression,
3240
Node,
3341
ObjectExpression,
3442
ObjectPattern,
3543
Property,
44+
RestElement,
45+
TSTypeParameter,
3646
VariableDeclaration,
3747
VariableDeclarator,
3848
} = jscs;
@@ -291,8 +301,6 @@ function maybeRenameNode(ast: AST, identifierPath: ASTPath<IdentifierNode>, alia
291301
// it means we potentially need to rename something *not* already named `getServerSideProps`, `getStaticProps`, or
292302
// `getStaticPaths`, meaning we need to rename nodes outside of the collection upon which we're currently acting.
293303
if (ExportSpecifier.check(parent)) {
294-
// console.log(node);
295-
// debugger;
296304
if (parent.exported.name !== parent.local?.name && node === parent.exported) {
297305
const currentLocalName = parent.local?.name || '';
298306
renameIdentifiers(ast, currentLocalName, alias);
@@ -320,3 +328,202 @@ export function removeComments(ast: AST): void {
320328
const nodesWithComments = ast.find(Node).filter(nodePath => !!nodePath.node.comments);
321329
nodesWithComments.forEach(nodePath => (nodePath.node.comments = null));
322330
}
331+
332+
/**
333+
* Determines from a given AST of a file whether the file has a default export or not.
334+
*/
335+
export function hasDefaultExport(ast: AST): boolean {
336+
const defaultExports = ast.find(Node, value => {
337+
return (
338+
ExportDefaultDeclaration.check(value) ||
339+
ExportDefaultSpecifier.check(value) ||
340+
(ExportSpecifier.check(value) && value.exported.name === 'default')
341+
);
342+
});
343+
344+
// In theory there should only ever be 0 or 1, but who knows what people do
345+
return defaultExports.length > 0;
346+
}
347+
348+
/**
349+
* Extracts all identifier names (`'constName'`) from an destructuringassignment'sArrayPattern (the `[constName]` in`const [constName] = [1]`).
350+
*
351+
* This function recursively calls itself and `getExportIdentifiersFromObjectPattern` since destructuring assignments
352+
* can be deeply nested with objects and arrays.
353+
*
354+
* Example - take the following program:
355+
*
356+
* ```js
357+
* export const [{ foo: name1 }, [{ bar: [name2]}, name3]] = [{ foo: 1 }, [{ bar: [2] }, 3]];
358+
* ```
359+
*
360+
* The `ArrayPattern` node in question for this program is the left hand side of the assignment:
361+
* `[{ foo: name1 }, [{ bar: [name2]}, name3]]`
362+
*
363+
* Applying this function to this `ArrayPattern` will return the following: `["name1", "name2", "name3"]`
364+
*
365+
* DISCLAIMER: This function only correcly extracts identifiers of `ArrayPatterns` in the context of export statements.
366+
* Using this for `ArrayPattern` outside of exports would require us to handle more edgecases. Hence the "Export" in
367+
* this function's name.
368+
*/
369+
function getExportIdentifiersFromArrayPattern(arrayPattern: jscsTypes.ArrayPattern): string[] {
370+
const identifiers: string[] = [];
371+
372+
arrayPattern.elements.forEach(element => {
373+
if (Identifier.check(element)) {
374+
identifiers.push(element.name);
375+
} else if (ObjectPattern.check(element)) {
376+
identifiers.push(...getExportIdentifiersFromObjectPattern(element));
377+
} else if (ArrayPattern.check(element)) {
378+
identifiers.push(...getExportIdentifiersFromArrayPattern(element));
379+
} else if (RestElement.check(element) && Identifier.check(element.argument)) {
380+
// `RestElements` are spread operators
381+
identifiers.push(element.argument.name);
382+
}
383+
});
384+
385+
return identifiers;
386+
}
387+
388+
/**
389+
* Grabs all identifiers from an ObjectPattern within a destructured named export declaration
390+
* statement (`name` in "export const { val: name } = { val: 1 }").
391+
*
392+
* This function recursively calls itself and `getExportIdentifiersFromArrayPattern` since destructuring assignments
393+
* can be deeply nested with objects and arrays.
394+
*
395+
* Example - take the following program:
396+
*
397+
* ```js
398+
* export const { foo: [{ bar: name1 }], name2, ...name3 } = { foo: [{}] };
399+
* ```
400+
*
401+
* The `ObjectPattern` node in question for this program is the left hand side of the assignment:
402+
* `{ foo: [{ bar: name1 }], name2, ...name3 } = { foo: [{}] }`
403+
*
404+
* Applying this function to this `ObjectPattern` will return the following: `["name1", "name2", "name3"]`
405+
*
406+
* DISCLAIMER: This function only correcly extracts identifiers of `ObjectPatterns` in the context of export statements.
407+
* Using this for `ObjectPatterns` outside of exports would require us to handle more edgecases. Hence the "Export" in
408+
* this function's name.
409+
*/
410+
function getExportIdentifiersFromObjectPattern(objectPatternNode: jscsTypes.ObjectPattern): string[] {
411+
const identifiers: string[] = [];
412+
413+
objectPatternNode.properties.forEach(property => {
414+
// An `ObjectPattern`'s properties can be either `Property`s or `RestElement`s.
415+
if (Property.check(property)) {
416+
if (Identifier.check(property.value)) {
417+
identifiers.push(property.value.name);
418+
} else if (ObjectPattern.check(property.value)) {
419+
identifiers.push(...getExportIdentifiersFromObjectPattern(property.value));
420+
} else if (ArrayPattern.check(property.value)) {
421+
identifiers.push(...getExportIdentifiersFromArrayPattern(property.value));
422+
} else if (RestElement.check(property.value) && Identifier.check(property.value.argument)) {
423+
// `RestElements` are spread operators
424+
identifiers.push(property.value.argument.name);
425+
}
426+
// @ts-ignore AST types are wrong here
427+
} else if (RestElement.check(property) && Identifier.check(property.argument)) {
428+
// `RestElements` are spread operators
429+
// @ts-ignore AST types are wrong here
430+
identifiers.push(property.argument.name as string);
431+
}
432+
});
433+
434+
return identifiers;
435+
}
436+
437+
/**
438+
* Given the AST of a file, this function extracts all named exports from the file.
439+
*
440+
* @returns a list of deduplicated identifiers.
441+
*/
442+
export function getExportIdentifierNames(ast: AST): string[] {
443+
// 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.
444+
const identifiers: string[] = [];
445+
446+
// The following variable collects all export statements that double as named declaration, e.g.:
447+
// - export function myFunc() {}
448+
// - export var myVar = 1337
449+
// - export const myConst = 1337
450+
// - export const { a, ..rest } = { a: 1, b: 2, c: 3 }
451+
// We will narrow those situations down in subsequent code blocks.
452+
const namedExportDeclarationNodeDeclarations = ast
453+
.find(ExportNamedDeclaration)
454+
.nodes()
455+
.map(namedExportDeclarationNode => namedExportDeclarationNode.declaration);
456+
457+
namedExportDeclarationNodeDeclarations
458+
.filter((declarationNode): declarationNode is jscsTypes.VariableDeclaration =>
459+
// Narrow down to varible declarations, e.g.:
460+
// export const a = ...;
461+
// export var b = ...;
462+
// export let c = ...;
463+
// export let c, d = 1;
464+
VariableDeclaration.check(declarationNode),
465+
)
466+
.map(
467+
variableDeclarationNode =>
468+
// Grab all declarations in a single export statement.
469+
// There can be multiple in the case of for example in `export let a, b;`.
470+
variableDeclarationNode.declarations,
471+
)
472+
.reduce((prev, curr) => [...prev, ...curr], []) // flatten - now we have all declaration nodes in one flat array
473+
.forEach(declarationNode => {
474+
if (
475+
Identifier.check(declarationNode) || // should never happen
476+
JSXIdentifier.check(declarationNode) || // JSX like `<name></name>` - we don't care about these
477+
TSTypeParameter.check(declarationNode) // type definitions - we don't care about those
478+
) {
479+
// We should never have to enter this branch, it is just for type narrowing.
480+
} else if (Identifier.check(declarationNode.id)) {
481+
// If it's a simple declaration with an identifier we collect it. (e.g. `const myIdentifier = 1;` -> "myIdentifier")
482+
identifiers.push(declarationNode.id.name);
483+
} else if (ObjectPattern.check(declarationNode.id)) {
484+
// If we encounter a destructuring export like `export const { foo: name1, bar: name2 } = { foo: 1, bar: 2 };`,
485+
// we try collecting the identifiers from the pattern `{ foo: name1, bar: name2 }`.
486+
identifiers.push(...getExportIdentifiersFromObjectPattern(declarationNode.id));
487+
} else if (ArrayPattern.check(declarationNode.id)) {
488+
// If we encounter a destructuring export like `export const [name1, name2] = [1, 2];`,
489+
// we try collecting the identifiers from the pattern `[name1, name2]`.
490+
identifiers.push(...getExportIdentifiersFromArrayPattern(declarationNode.id));
491+
}
492+
});
493+
494+
namedExportDeclarationNodeDeclarations
495+
.filter(
496+
// Narrow down to class and function declarations, e.g.:
497+
// export class Foo {};
498+
// export function bar() {};
499+
(declarationNode): declarationNode is jscsTypes.ClassDeclaration | jscsTypes.FunctionDeclaration =>
500+
ClassDeclaration.check(declarationNode) || FunctionDeclaration.check(declarationNode),
501+
)
502+
.map(node => node.id) // Grab the identifier of the function/class - Note: it might be `null` when it's anonymous
503+
.filter((id): id is jscsTypes.Identifier => Identifier.check(id)) // Elaborate way of null-checking
504+
.forEach(id => identifiers.push(id.name)); // Collect the name of the identifier
505+
506+
ast
507+
.find(ExportSpecifier) // Find stuff like `export {<id [as name]>} [from ...];`
508+
.nodes()
509+
.forEach(specifier => {
510+
// Taking the example above `specifier.exported.name` always contains `id` unless `name` is specified, then it's `name`;
511+
if (specifier.exported.name !== 'default') {
512+
// You can do default exports "export { something as default };" but we do not want to collect "default" in this
513+
// function since it only wants to collect named exports.
514+
identifiers.push(specifier.exported.name);
515+
}
516+
});
517+
518+
ast
519+
.find(ExportAllDeclaration) // Find stuff like `export * from ..." and "export * as someVariable from ...`
520+
.nodes()
521+
.forEach(declaration => {
522+
// Narrow it down to only find `export * as someVariable from ...` (emphasis on "as someVariable")
523+
if (declaration.exported) {
524+
identifiers.push(declaration.exported.name); // `declaration.exported.name` contains "someVariable"
525+
}
526+
});
527+
528+
return [...new Set(identifiers)]; // dedupe
529+
}

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

+80-32
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,26 @@
66
* manipulating them, and then turning them back into strings and appending our template code to the user's (modified)
77
* page code. Greater detail and explanations can be found in situ in the functions below and in the helper functions in
88
* `ast.ts`.
9+
*
10+
* For `getInitialProps` we create a virtual proxy-module that re-exports all the exports and default exports of the
11+
* original file and wraps `getInitialProps`. We do this since it allows us to very generically wrap `getInitialProps`
12+
* for all kinds ways users might define default exports (which are a lot of ways).
913
*/
10-
1114
import { logger } from '@sentry/utils';
1215
import * as fs from 'fs';
1316
import * as path from 'path';
1417

1518
import { isESM } from '../../utils/isESM';
1619
import type { AST } from './ast';
17-
import { findDeclarations, findExports, makeAST, removeComments, renameIdentifiers } from './ast';
20+
import {
21+
findDeclarations,
22+
findExports,
23+
getExportIdentifierNames,
24+
hasDefaultExport,
25+
makeAST,
26+
removeComments,
27+
renameIdentifiers,
28+
} from './ast';
1829
import type { LoaderThis } from './types';
1930

2031
// 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)
94105
* Wrap `getStaticPaths`, `getStaticProps`, and `getServerSideProps` (if they exist) in the given page code
95106
*/
96107
export default 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-
101108
// For now this loader only works for ESM code
102109
if (!isESM(userCode)) {
103110
return userCode;
104111
}
105112

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-
}
113+
// We know one or the other will be defined, depending on the version of webpack being used
114+
const { projectDir } = 'getOptions' in this ? this.getOptions() : this.query;
116115

117-
const templatePath = path.resolve(__dirname, '../templates/dataFetchersLoaderTemplate.js');
118-
// make sure the template is included when runing `webpack watch`
119-
this.addDependency(templatePath);
116+
// In the following branch we will proxy the user's file. This means we return code (basically an entirely new file)
117+
// that re - exports all the user file's originial export, but with a "sentry-proxy-loader" query in the module
118+
// string.
119+
// This looks like the following: `export { a, b, c } from "[imagine userfile path here]?sentry-proxy-loader";`
120+
// Additionally, in this proxy file we import the userfile's default export, wrap `getInitialProps` on that default
121+
// export, and re -export the now modified default export as default.
122+
// Webpack will resolve the module with the "sentry-proxy-loader" query to the original file, but will give us access
123+
// to the query via`this.resourceQuery`. If we see that `this.resourceQuery` includes includes "sentry-proxy-loader"
124+
// we know we're in a proxied file and do not need to proxy again.
120125

121-
const templateCode = fs.readFileSync(templatePath).toString();
126+
if (!this.resourceQuery.includes('sentry-proxy-loader')) {
127+
const ast = makeAST(userCode, true); // is there a reason to ever parse without typescript?
122128

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+
const exportedIdentifiers = getExportIdentifierNames(ast);
129130

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-
}
131+
let outputFileContent = '';
132+
133+
if (exportedIdentifiers.length > 0) {
134+
outputFileContent += `export { ${exportedIdentifiers.join(', ')} } from "${
135+
this.resourcePath
136+
}?sentry-proxy-loader";`;
137+
}
138+
139+
if (hasDefaultExport(ast)) {
140+
outputFileContent += `
141+
import { default as _sentry_default } from "${this.resourcePath}?sentry-proxy-loader";
142+
import { withSentryGetInitialProps } from "@sentry/nextjs";
135143
136-
return `${modifiedUserCode}\n${injectedCode}`;
144+
if (typeof _sentry_default.getInitialProps === 'function') {
145+
_sentry_default.getInitialProps = withSentryGetInitialProps(_sentry_default.getInitialProps);
146+
}
147+
148+
export default _sentry_default;`;
149+
}
150+
151+
return outputFileContent;
152+
} else {
153+
// 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
154+
// simple substring match (rather than waiting until we've parsed the code) because it's meant to be an
155+
// as-fast-as-possible fail-fast. It's possible for user code to pass this check, even if it contains none of the
156+
// functions in question, just by virtue of the correct string having been found, be it in a comment, as part of a
157+
// longer variable name, etc. That said, when we actually do the code manipulation we'll be working on the code's AST,
158+
// meaning we'll be able to differentiate between code we actually want to change and any false positives which might
159+
// come up here.)
160+
if (Object.keys(DATA_FETCHING_FUNCTIONS).every(functionName => !userCode.includes(functionName))) {
161+
return userCode;
162+
}
163+
164+
const templatePath = path.resolve(__dirname, '../templates/dataFetchersLoaderTemplate.js');
165+
// make sure the template is included when runing `webpack watch`
166+
this.addDependency(templatePath);
167+
168+
const templateCode = fs.readFileSync(templatePath).toString();
169+
170+
const [modifiedUserCode, modifiedTemplateCode] = wrapFunctions(
171+
userCode,
172+
templateCode,
173+
// Relative path to the page we're currently processing, for use in error messages
174+
path.relative(projectDir, this.resourcePath),
175+
);
176+
177+
// Fill in template placeholders
178+
let injectedCode = modifiedTemplateCode;
179+
for (const { placeholder, alias } of Object.values(DATA_FETCHING_FUNCTIONS)) {
180+
injectedCode = injectedCode.replace(new RegExp(placeholder, 'g'), alias);
181+
}
182+
183+
return `${modifiedUserCode}\n${injectedCode}`;
184+
}
137185
}

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

+1-2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ type LoaderOptions = {
1212
*/
1313
export default function prefixLoader(this: LoaderThis<LoaderOptions>, userCode: string): string {
1414
// We know one or the other will be defined, depending on the version of webpack being used
15-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
16-
const { distDir } = this.getOptions ? this.getOptions() : this.query!;
15+
const { distDir } = 'getOptions' in this ? this.getOptions() : this.query;
1716

1817
const templatePath = path.resolve(__dirname, '../templates/prefixLoaderTemplate.js');
1918
// make sure the template is included when runing `webpack watch`

0 commit comments

Comments
 (0)