Skip to content

Commit 4d5f13e

Browse files
authored
feat(nextjs): Auto-wrap edge-routes and middleware (#6746)
1 parent dcc7680 commit 4d5f13e

File tree

8 files changed

+173
-46
lines changed

8 files changed

+173
-46
lines changed

packages/nextjs/rollup.npm.config.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export default [
1414
),
1515
...makeNPMConfigVariants(
1616
makeBaseNPMConfig({
17-
entrypoints: ['src/config/templates/pageWrapperTemplate.ts', 'src/config/templates/apiWrapperTemplate.ts'],
17+
entrypoints: [
18+
'src/config/templates/pageWrapperTemplate.ts',
19+
'src/config/templates/apiWrapperTemplate.ts',
20+
'src/config/templates/middlewareWrapperTemplate.ts',
21+
],
1822

1923
packageSpecificConfig: {
2024
output: {
@@ -29,7 +33,7 @@ export default [
2933
// make it so Rollup calms down about the fact that we're combining default and named exports
3034
exports: 'named',
3135
},
32-
external: ['@sentry/nextjs', '__SENTRY_WRAPPING_TARGET__'],
36+
external: ['@sentry/nextjs', '__SENTRY_WRAPPING_TARGET_FILE__'],
3337
},
3438
}),
3539
),

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encodin
1212
const pageWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'pageWrapperTemplate.js');
1313
const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encoding: 'utf8' });
1414

15+
const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js');
16+
const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' });
17+
1518
// Just a simple placeholder to make referencing module consistent
1619
const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module';
1720

1821
// Needs to end in .cjs in order for the `commonjs` plugin to pick it up
19-
const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET__.cjs';
22+
const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs';
2023

2124
type LoaderOptions = {
2225
pagesDir: string;
2326
pageExtensionRegex: string;
2427
excludeServerRoutes: Array<RegExp | string>;
25-
isEdgeRuntime: boolean;
2628
};
2729

2830
/**
@@ -40,14 +42,8 @@ export default function wrappingLoader(
4042
pagesDir,
4143
pageExtensionRegex,
4244
excludeServerRoutes = [],
43-
isEdgeRuntime,
4445
} = 'getOptions' in this ? this.getOptions() : this.query;
4546

46-
// We currently don't support the edge runtime
47-
if (isEdgeRuntime) {
48-
return userCode;
49-
}
50-
5147
this.async();
5248

5349
// Get the parameterized route name from this page's filepath
@@ -71,13 +67,23 @@ export default function wrappingLoader(
7167
return;
7268
}
7369

74-
let templateCode = parameterizedRoute.startsWith('/api') ? apiWrapperTemplateCode : pageWrapperTemplateCode;
70+
const middlewareJsPath = path.join(pagesDir, '..', 'middleware.js');
71+
const middlewareTsPath = path.join(pagesDir, '..', 'middleware.js');
72+
73+
let templateCode: string;
74+
if (parameterizedRoute.startsWith('/api')) {
75+
templateCode = apiWrapperTemplateCode;
76+
} else if (this.resourcePath === middlewareJsPath || this.resourcePath === middlewareTsPath) {
77+
templateCode = middlewareWrapperTemplateCode;
78+
} else {
79+
templateCode = pageWrapperTemplateCode;
80+
}
7581

7682
// Inject the route and the path to the file we're wrapping into the template
7783
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedRoute.replace(/\\/g, '\\\\'));
7884

7985
// Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand.
80-
templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET__/g, WRAPPING_TARGET_MODULE_NAME);
86+
templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME);
8187

8288
// Run the proxy module code through Rollup, in order to split the `export * from '<wrapped file>'` out into
8389
// individual exports (which nextjs seems to require).

packages/nextjs/src/config/templates/apiWrapperTemplate.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
/**
1+
/*
22
* This file is a template for the code which will be substituted when our webpack loader handles API files in the
33
* `pages/` directory.
44
*
5-
* We use `__RESOURCE_PATH__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
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,
66
* this causes both TS and ESLint to complain, hence the pragma comments below.
77
*/
88

99
// @ts-ignore See above
1010
// eslint-disable-next-line import/no-unresolved
11-
import * as origModule from '__SENTRY_WRAPPING_TARGET__';
11+
import * as origModule from '__SENTRY_WRAPPING_TARGET_FILE__';
1212
// eslint-disable-next-line import/no-extraneous-dependencies
1313
import * as Sentry from '@sentry/nextjs';
1414
import type { PageConfig } from 'next';
@@ -60,4 +60,4 @@ export default userProvidedHandler ? Sentry.withSentryAPI(userProvidedHandler, '
6060
// not include anything whose name matchs something we've explicitly exported above.
6161
// @ts-ignore See above
6262
// eslint-disable-next-line import/no-unresolved
63-
export * from '__SENTRY_WRAPPING_TARGET__';
63+
export * from '__SENTRY_WRAPPING_TARGET_FILE__';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This file is a template for the code which will be substituted when our webpack loader handles middleware files.
3+
*
4+
* We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
5+
* this causes both TS and ESLint to complain, hence the pragma comments below.
6+
*/
7+
8+
// @ts-ignore See above
9+
// eslint-disable-next-line import/no-unresolved
10+
import * as origModule from '__SENTRY_WRAPPING_TARGET_FILE__';
11+
// eslint-disable-next-line import/no-extraneous-dependencies
12+
import * as Sentry from '@sentry/nextjs';
13+
14+
import type { EdgeRouteHandler } from '../../edge/types';
15+
16+
type NextApiModule =
17+
| {
18+
// ESM export
19+
default?: EdgeRouteHandler;
20+
middleware?: EdgeRouteHandler;
21+
}
22+
// CJS export
23+
| EdgeRouteHandler;
24+
25+
const userApiModule = origModule as NextApiModule;
26+
27+
// Default to undefined. It's possible for Next.js users to not define any exports/handlers in an API route. If that is
28+
// the case Next.js wil crash during runtime but the Sentry SDK should definitely not crash so we need tohandle it.
29+
let userProvidedNamedHandler: EdgeRouteHandler | undefined = undefined;
30+
let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined;
31+
32+
if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') {
33+
// Handle when user defines via named ESM export: `export { middleware };`
34+
userProvidedNamedHandler = userApiModule.middleware;
35+
} else if ('default' in userApiModule && typeof userApiModule.default === 'function') {
36+
// Handle when user defines via ESM export: `export default myFunction;`
37+
userProvidedDefaultHandler = userApiModule.default;
38+
} else if (typeof userApiModule === 'function') {
39+
// Handle when user defines via CJS export: "module.exports = myFunction;"
40+
userProvidedDefaultHandler = userApiModule;
41+
}
42+
43+
export const middleware = userProvidedNamedHandler ? Sentry.withSentryMiddleware(userProvidedNamedHandler) : undefined;
44+
export default userProvidedDefaultHandler ? Sentry.withSentryMiddleware(userProvidedDefaultHandler) : undefined;
45+
46+
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to
47+
// not include anything whose name matchs something we've explicitly exported above.
48+
// @ts-ignore See above
49+
// eslint-disable-next-line import/no-unresolved
50+
export * from '__SENTRY_WRAPPING_TARGET_FILE__';

packages/nextjs/src/config/templates/pageWrapperTemplate.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
/**
1+
/*
22
* This file is a template for the code which will be substituted when our webpack loader handles non-API files in the
33
* `pages/` directory.
44
*
5-
* We use `__RESOURCE_PATH__` as a placeholder for the path to the file being wrapped. Because it's not a real package,
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,
66
* this causes both TS and ESLint to complain, hence the pragma comments below.
77
*/
88

99
// @ts-ignore See above
1010
// eslint-disable-next-line import/no-unresolved
11-
import * as wrapee from '__SENTRY_WRAPPING_TARGET__';
11+
import * as wrapee from '__SENTRY_WRAPPING_TARGET_FILE__';
1212
// eslint-disable-next-line import/no-extraneous-dependencies
1313
import * as Sentry from '@sentry/nextjs';
1414
import type { GetServerSideProps, GetStaticProps, NextPage as NextPageComponent } from 'next';
@@ -54,4 +54,4 @@ export default pageComponent;
5454
// not include anything whose name matchs something we've explicitly exported above.
5555
// @ts-ignore See above
5656
// eslint-disable-next-line import/no-unresolved
57-
export * from '__SENTRY_WRAPPING_TARGET__';
57+
export * from '__SENTRY_WRAPPING_TARGET_FILE__';

packages/nextjs/src/config/types.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type ExportedNextConfig = NextConfigObjectWithSentry | NextConfigFunction
2222
export type NextConfigObjectWithSentry = NextConfigObject & {
2323
sentry?: UserSentryOptions;
2424
};
25+
2526
export type NextConfigFunctionWithSentry = (
2627
phase: string,
2728
defaults: { defaultConfig: NextConfigObject },
@@ -60,39 +61,67 @@ export type NextConfigObject = {
6061
};
6162

6263
export type UserSentryOptions = {
63-
// Override the SDK's default decision about whether or not to enable to the webpack plugin. Note that `false` forces
64-
// the plugin to be enabled, even in situations where it's not recommended.
64+
/**
65+
* Override the SDK's default decision about whether or not to enable to the Sentry webpack plugin for server files.
66+
* Note that `false` forces the plugin to be enabled, even in situations where it's not recommended.
67+
*/
6568
disableServerWebpackPlugin?: boolean;
69+
70+
/**
71+
* Override the SDK's default decision about whether or not to enable to the Sentry webpack plugin for client files.
72+
* Note that `false` forces the plugin to be enabled, even in situations where it's not recommended.
73+
*/
6674
disableClientWebpackPlugin?: boolean;
6775

68-
// Use `hidden-source-map` for webpack `devtool` option, which strips the `sourceMappingURL` from the bottom of built
69-
// JS files
76+
/**
77+
* Use `hidden-source-map` for webpack `devtool` option, which strips the `sourceMappingURL` from the bottom of built
78+
* JS files.
79+
*/
7080
hideSourceMaps?: boolean;
7181

72-
// Force webpack to apply the same transpilation rules to the SDK code as apply to user code. Helpful when targeting
73-
// older browsers which don't support ES6 (or ES6+ features like object spread).
82+
/**
83+
* Instructs webpack to apply the same transpilation rules to the SDK code as apply to user code. Helpful when
84+
* targeting older browsers which don't support ES6 (or ES6+ features like object spread).
85+
*/
7486
transpileClientSDK?: boolean;
7587

76-
// Upload files from `<distDir>/static/chunks` rather than `<distDir>/static/chunks/pages`. Usually files outside of
77-
// `pages/` only contain third-party code, but in cases where they contain user code, restricting the webpack
78-
// plugin's upload breaks sourcemaps for those user-code-containing files, because it keeps them from being
79-
// uploaded. At the same time, we don't want to widen the scope if we don't have to, because we're guaranteed to end
80-
// up uploading too many files, which is why this defaults to `false`.
88+
/**
89+
* Instructs the Sentry webpack plugin to upload source files from `<distDir>/static/chunks` rather than
90+
* `<distDir>/static/chunks/pages`. Usually files outside of `pages/` only contain third-party code, but in cases
91+
* where they contain user code, restricting the webpack plugin's upload breaks sourcemaps for those
92+
* user-code-containing files, because it keeps them from being uploaded. Defaults to `false`.
93+
*/
94+
// We don't want to widen the scope if we don't have to, because we're guaranteed to end up uploading too many files,
95+
// which is why this defaults to`false`.
8196
widenClientFileUpload?: boolean;
8297

83-
// Automatically instrument Next.js data fetching methods and Next.js API routes
98+
/**
99+
* Automatically instrument Next.js data fetching methods and Next.js API routes with error and performance monitoring.
100+
* Defaults to `true`.
101+
*/
84102
autoInstrumentServerFunctions?: boolean;
85103

86-
// Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of
87-
// strings or regular expressions.
88-
//
89-
// NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths
90-
// (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full,
91-
// exact match.
104+
/**
105+
* Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`.
106+
*/
107+
autoInstrumentMiddleware?: boolean;
108+
109+
/**
110+
* Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of
111+
* strings or regular expressions.
112+
*
113+
* NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths
114+
* (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full,
115+
* exact match.
116+
*/
92117
excludeServerRoutes?: Array<RegExp | string>;
93118

94-
// Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events from being sent.
95-
// This option should be a path (for example: '/error-monitoring').
119+
/**
120+
* Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events
121+
* from being sent. This option should be a path (for example: '/error-monitoring').
122+
*
123+
* NOTE: This feature only works with Next.js 11+
124+
*/
96125
tunnelRoute?: string;
97126
};
98127

@@ -164,7 +193,7 @@ export type EntryPointObject = { import: string | Array<string> };
164193
*/
165194

166195
export type WebpackModuleRule = {
167-
test?: string | RegExp;
196+
test?: string | RegExp | ((resourcePath: string) => boolean);
168197
include?: Array<string | RegExp> | RegExp;
169198
exclude?: (filepath: string) => boolean;
170199
use?: ModuleRuleUseProperty | Array<ModuleRuleUseProperty>;

packages/nextjs/src/config/webpack.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,23 +99,61 @@ export function constructWebpackConfigFunction(
9999

100100
if (isServer) {
101101
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
102-
const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string;
102+
let pagesDirPath: string;
103+
if (
104+
fs.existsSync(path.join(projectDir, 'pages')) &&
105+
fs.lstatSync(path.join(projectDir, 'pages')).isDirectory()
106+
) {
107+
pagesDirPath = path.join(projectDir, 'pages');
108+
} else {
109+
pagesDirPath = path.join(projectDir, 'src', 'pages');
110+
}
111+
112+
const middlewareJsPath = path.join(pagesDirPath, '..', 'middleware.js');
113+
const middlewareTsPath = path.join(pagesDirPath, '..', 'middleware.ts');
103114

104115
// Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161
105116
const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js'];
117+
const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`);
106118
const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|');
107119

108120
// It is very important that we insert our loader at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened.
109121
newConfig.module.rules.unshift({
110-
test: new RegExp(`^${escapeStringForRegex(pagesDir)}.*\\.(${pageExtensionRegex})$`),
122+
test: resourcePath => {
123+
// We generally want to apply the loader to all API routes, pages and to the middleware file.
124+
125+
// `resourcePath` may be an absolute path or a path relative to the context of the webpack config
126+
let absoluteResourcePath: string;
127+
if (path.isAbsolute(resourcePath)) {
128+
absoluteResourcePath = resourcePath;
129+
} else {
130+
absoluteResourcePath = path.join(projectDir, resourcePath);
131+
}
132+
const normalizedAbsoluteResourcePath = path.normalize(absoluteResourcePath);
133+
134+
if (
135+
// Match everything inside pages/ with the appropriate file extension
136+
normalizedAbsoluteResourcePath.startsWith(pagesDirPath) &&
137+
dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
138+
) {
139+
return true;
140+
} else if (
141+
// Match middleware.js and middleware.ts
142+
normalizedAbsoluteResourcePath === middlewareJsPath ||
143+
normalizedAbsoluteResourcePath === middlewareTsPath
144+
) {
145+
return userSentryOptions.autoInstrumentMiddleware ?? true;
146+
} else {
147+
return false;
148+
}
149+
},
111150
use: [
112151
{
113152
loader: path.resolve(__dirname, 'loaders/wrappingLoader.js'),
114153
options: {
115-
pagesDir,
154+
pagesDir: pagesDirPath,
116155
pageExtensionRegex,
117156
excludeServerRoutes: userSentryOptions.excludeServerRoutes,
118-
isEdgeRuntime: buildContext.nextRuntime === 'edge',
119157
},
120158
},
121159
],

packages/nextjs/src/edge/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// We cannot make any assumptions about what users define as their handler except maybe that it is a function
22
export interface EdgeRouteHandler {
33
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4-
(req: any): any | Promise<any>;
4+
(...args: any[]): any;
55
}

0 commit comments

Comments
 (0)