Skip to content

Commit 0ea7a86

Browse files
authored
feat(nextjs): Add auto-wrapping for server components (#6953)
1 parent 7532522 commit 0ea7a86

13 files changed

+460
-73
lines changed

packages/nextjs/rollup.npm.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default [
1111
'src/client/index.ts',
1212
'src/server/index.ts',
1313
'src/edge/index.ts',
14-
'src/config/webpack.ts',
14+
'src/config/index.ts',
1515
],
1616

1717
// prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because
@@ -25,6 +25,7 @@ export default [
2525
'src/config/templates/pageWrapperTemplate.ts',
2626
'src/config/templates/apiWrapperTemplate.ts',
2727
'src/config/templates/middlewareWrapperTemplate.ts',
28+
'src/config/templates/serverComponentWrapperTemplate.ts',
2829
],
2930

3031
packageSpecificConfig: {

packages/nextjs/src/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,5 @@ export {
114114
withSentryServerSideErrorGetInitialProps,
115115
wrapErrorGetInitialPropsWithSentry,
116116
} from './wrapErrorGetInitialPropsWithSentry';
117+
118+
export { wrapAppDirComponentWithSentry } from './wrapAppDirComponentWithSentry';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Currently just a pass-through to provide isomorphism for the client. May be used in the future to add instrumentation
3+
* for client components.
4+
*/
5+
export function wrapAppDirComponentWithSentry(wrappingTarget: any): any {
6+
return wrappingTarget;
7+
}

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

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encod
1515
const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js');
1616
const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' });
1717

18+
const serverComponentWrapperTemplatePath = path.resolve(
19+
__dirname,
20+
'..',
21+
'templates',
22+
'serverComponentWrapperTemplate.js',
23+
);
24+
const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' });
25+
1826
// Just a simple placeholder to make referencing module consistent
1927
const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';
2028

@@ -23,8 +31,10 @@ const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';
2331

2432
type LoaderOptions = {
2533
pagesDir: string;
34+
appDir: string;
2635
pageExtensionRegex: string;
2736
excludeServerRoutes: Array<RegExp | string>;
37+
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'page-server-component';
2838
};
2939

3040
/**
@@ -36,52 +46,91 @@ export default function wrappingLoader(
3646
this: LoaderThis<LoaderOptions>,
3747
userCode: string,
3848
userModuleSourceMap: any,
39-
): void | string {
49+
): void {
4050
// We know one or the other will be defined, depending on the version of webpack being used
4151
const {
4252
pagesDir,
53+
appDir,
4354
pageExtensionRegex,
4455
excludeServerRoutes = [],
56+
wrappingTargetKind,
4557
} = 'getOptions' in this ? this.getOptions() : this.query;
4658

4759
this.async();
4860

49-
// Get the parameterized route name from this page's filepath
50-
const parameterizedRoute = path
51-
// Get the path of the file insde of the pages directory
52-
.relative(pagesDir, this.resourcePath)
53-
// Add a slash at the beginning
54-
.replace(/(.*)/, '/$1')
55-
// Pull off the file extension
56-
.replace(new RegExp(`\\.(${pageExtensionRegex})`), '')
57-
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
58-
// just `/xyz`
59-
.replace(/\/index$/, '')
60-
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
61-
// homepage), sub back in the root route
62-
.replace(/^$/, '/');
63-
64-
// Skip explicitly-ignored pages
65-
if (stringMatchesSomePattern(parameterizedRoute, excludeServerRoutes, true)) {
66-
this.callback(null, userCode, userModuleSourceMap);
67-
return;
68-
}
61+
let templateCode: string;
6962

70-
const middlewareJsPath = path.join(pagesDir, '..', 'middleware.js');
71-
const middlewareTsPath = path.join(pagesDir, '..', 'middleware.ts');
63+
if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') {
64+
// Get the parameterized route name from this page's filepath
65+
const parameterizedPagesRoute = path.posix
66+
.normalize(
67+
path
68+
// Get the path of the file insde of the pages directory
69+
.relative(pagesDir, this.resourcePath),
70+
)
71+
// Add a slash at the beginning
72+
.replace(/(.*)/, '/$1')
73+
// Pull off the file extension
74+
.replace(new RegExp(`\\.(${pageExtensionRegex})`), '')
75+
// Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into
76+
// just `/xyz`
77+
.replace(/\/index$/, '')
78+
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
79+
// homepage), sub back in the root route
80+
.replace(/^$/, '/');
7281

73-
let templateCode: string;
74-
if (parameterizedRoute.startsWith('/api')) {
75-
templateCode = apiWrapperTemplateCode;
76-
} else if (this.resourcePath === middlewareJsPath || this.resourcePath === middlewareTsPath) {
82+
// Skip explicitly-ignored pages
83+
if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
84+
this.callback(null, userCode, userModuleSourceMap);
85+
return;
86+
}
87+
88+
if (wrappingTargetKind === 'page') {
89+
templateCode = pageWrapperTemplateCode;
90+
} else if (wrappingTargetKind === 'api-route') {
91+
templateCode = apiWrapperTemplateCode;
92+
} else {
93+
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
94+
}
95+
96+
// Inject the route and the path to the file we're wrapping into the template
97+
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
98+
} else if (wrappingTargetKind === 'page-server-component') {
99+
// Get the parameterized route name from this page's filepath
100+
const parameterizedPagesRoute = path.posix
101+
.normalize(path.relative(appDir, this.resourcePath))
102+
// Add a slash at the beginning
103+
.replace(/(.*)/, '/$1')
104+
// Pull off the file name
105+
.replace(/\/page\.(js|jsx|tsx)$/, '')
106+
// Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts
107+
.replace(/\/(\(.*?\)\/)+/g, '/')
108+
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
109+
// homepage), sub back in the root route
110+
.replace(/^$/, '/');
111+
112+
// Skip explicitly-ignored pages
113+
if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) {
114+
this.callback(null, userCode, userModuleSourceMap);
115+
return;
116+
}
117+
118+
// The following string is what Next.js injects in order to mark client components:
119+
// https://github.com/vercel/next.js/blob/295f9da393f7d5a49b0c2e15a2f46448dbdc3895/packages/next/build/analysis/get-page-static-info.ts#L37
120+
// https://github.com/vercel/next.js/blob/a1c15d84d906a8adf1667332a3f0732be615afa0/packages/next-swc/crates/core/src/react_server_components.rs#L247
121+
// We do not want to wrap client components
122+
if (userCode.includes('/* __next_internal_client_entry_do_not_use__ */')) {
123+
this.callback(null, userCode, userModuleSourceMap);
124+
return;
125+
}
126+
127+
templateCode = serverComponentWrapperTemplateCode;
128+
} else if (wrappingTargetKind === 'middleware') {
77129
templateCode = middlewareWrapperTemplateCode;
78130
} else {
79-
templateCode = pageWrapperTemplateCode;
131+
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
80132
}
81133

82-
// Inject the route and the path to the file we're wrapping into the template
83-
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedRoute.replace(/\\/g, '\\\\'));
84-
85134
// Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand.
86135
templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME);
87136

@@ -97,7 +146,6 @@ export default function wrappingLoader(
97146
`[@sentry/nextjs] Could not instrument ${this.resourcePath}. An error occurred while auto-wrapping:\n${err}`,
98147
);
99148
this.callback(null, userCode, userModuleSourceMap);
100-
return;
101149
});
102150
}
103151

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* This file is a template for the code which will be substituted when our webpack loader handles non-API files in the
3+
* `pages/` directory.
4+
*
5+
* We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
6+
* this causes both TS and ESLint to complain, hence the pragma comments below.
7+
*/
8+
9+
// @ts-ignore See above
10+
// eslint-disable-next-line import/no-unresolved
11+
import * as wrapee from '__SENTRY_WRAPPING_TARGET_FILE__';
12+
// eslint-disable-next-line import/no-extraneous-dependencies
13+
import * as Sentry from '@sentry/nextjs';
14+
15+
type ServerComponentModule = {
16+
default: unknown;
17+
};
18+
19+
const serverComponentModule = wrapee as ServerComponentModule;
20+
21+
const serverComponent = serverComponentModule.default;
22+
23+
let wrappedServerComponent;
24+
if (typeof serverComponent === 'function') {
25+
wrappedServerComponent = Sentry.wrapAppDirComponentWithSentry(serverComponent);
26+
} else {
27+
wrappedServerComponent = serverComponent;
28+
}
29+
30+
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to
31+
// not include anything whose name matchs something we've explicitly exported above.
32+
// @ts-ignore See above
33+
// eslint-disable-next-line import/no-unresolved
34+
export * from '__SENTRY_WRAPPING_TARGET_FILE__';
35+
36+
export default wrappedServerComponent;

packages/nextjs/src/config/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,14 @@ export type UserSentryOptions = {
106106
*/
107107
autoInstrumentMiddleware?: boolean;
108108

109+
/**
110+
* Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`.
111+
*/
112+
autoInstrumentAppDirectory?: boolean;
113+
109114
/**
110115
* Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of
111-
* strings or regular expressions.
116+
* strings or regular expressions. This options also affects pages in the `app` directory.
112117
*
113118
* NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths
114119
* (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full,

0 commit comments

Comments
 (0)