From 416a7c87ada7072bdc67dfd5ad2324c5877fbac8 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 13 Jan 2023 16:52:28 +0000 Subject: [PATCH] feat(nextjs): Auto-wrap edge-routes and middleware --- packages/nextjs/rollup.npm.config.js | 6 ++- .../src/config/loaders/wrappingLoader.ts | 21 +++++--- .../templates/middlewareWrapperTemplate.ts | 53 +++++++++++++++++++ packages/nextjs/src/config/types.ts | 2 +- packages/nextjs/src/config/webpack.ts | 46 ++++++++++++++-- 5 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts diff --git a/packages/nextjs/rollup.npm.config.js b/packages/nextjs/rollup.npm.config.js index db1fa9c1fde1..5268746a7e63 100644 --- a/packages/nextjs/rollup.npm.config.js +++ b/packages/nextjs/rollup.npm.config.js @@ -14,7 +14,11 @@ export default [ ), ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/config/templates/pageWrapperTemplate.ts', 'src/config/templates/apiWrapperTemplate.ts'], + entrypoints: [ + 'src/config/templates/pageWrapperTemplate.ts', + 'src/config/templates/apiWrapperTemplate.ts', + 'src/config/templates/middlewareWrapperTemplate.ts', + ], packageSpecificConfig: { output: { diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index f092216bbf1d..5dee8983ef11 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -12,6 +12,9 @@ const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encodin const pageWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'pageWrapperTemplate.js'); const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encoding: 'utf8' }); +const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js'); +const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' }); + // Just a simple placeholder to make referencing module consistent const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module'; @@ -40,14 +43,8 @@ export default function wrappingLoader( pagesDir, pageExtensionRegex, excludeServerRoutes = [], - isEdgeRuntime, } = 'getOptions' in this ? this.getOptions() : this.query; - // We currently don't support the edge runtime - if (isEdgeRuntime) { - return userCode; - } - this.async(); // Get the parameterized route name from this page's filepath @@ -71,7 +68,17 @@ export default function wrappingLoader( return; } - let templateCode = parameterizedRoute.startsWith('/api') ? apiWrapperTemplateCode : pageWrapperTemplateCode; + const middlewareJsPath = path.join(pagesDir, '..', 'middleware.js'); + const middlewareTsPath = path.join(pagesDir, '..', 'middleware.js'); + + let templateCode: string; + if (parameterizedRoute.startsWith('/api')) { + templateCode = apiWrapperTemplateCode; + } else if (this.resourcePath === middlewareJsPath || this.resourcePath === middlewareTsPath) { + templateCode = middlewareWrapperTemplateCode; + } else { + templateCode = pageWrapperTemplateCode; + } // Inject the route and the path to the file we're wrapping into the template templateCode = templateCode.replace(/__ROUTE__/g, parameterizedRoute.replace(/\\/g, '\\\\')); diff --git a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts new file mode 100644 index 000000000000..40cfedfce8d8 --- /dev/null +++ b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts @@ -0,0 +1,53 @@ +/** + * This file is a template for the code which will be substituted when our webpack loader handles API files in the + * `pages/` directory. + * + * We use `__RESOURCE_PATH__` as a placeholder for the path to the file being wrapped. Because it's not a real package, + * this causes both TS and ESLint to complain, hence the pragma comments below. + */ + +// @ts-ignore See above +// eslint-disable-next-line import/no-unresolved +import * as origModule from '__SENTRY_WRAPPING_TARGET__'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as Sentry from '@sentry/nextjs'; + +// We import this from `wrappers` rather than directly from `next` because our version can work simultaneously with +// multiple versions of next. See note in `wrappers/types` for more. +import type { NextApiHandler } from '../../server/types'; + +type NextApiModule = + | { + // ESM export + default?: NextApiHandler; // TODO CHANGE THIS TYPE + middleware?: NextApiHandler; // TODO CHANGE THIS TYPE + } + // CJS export + | NextApiHandler; + +const userApiModule = origModule as NextApiModule; + +// Default to undefined. It's possible for Next.js users to not define any exports/handlers in an API route. If that is +// the case Next.js wil crash during runtime but the Sentry SDK should definitely not crash so we need tohandle it. +let userProvidedNamedHandler: NextApiHandler | undefined = undefined; +let userProvidedDefaultHandler: NextApiHandler | undefined = undefined; + +if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') { + // Handle when user defines via named ESM export: `export { middleware };` + userProvidedNamedHandler = userApiModule.middleware; +} else if ('default' in userApiModule && typeof userApiModule.default === 'function') { + // Handle when user defines via ESM export: `export default myFunction;` + userProvidedDefaultHandler = userApiModule.default; +} else if (typeof userApiModule === 'function') { + // Handle when user defines via CJS export: "module.exports = myFunction;" + userProvidedDefaultHandler = userApiModule; +} + +export const middleware = userProvidedNamedHandler ? Sentry.withSentryMiddleware(userProvidedNamedHandler) : undefined; +export default userProvidedDefaultHandler ? Sentry.withSentryMiddleware(userProvidedDefaultHandler) : undefined; + +// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to +// not include anything whose name matchs something we've explicitly exported above. +// @ts-ignore See above +// eslint-disable-next-line import/no-unresolved +export * from '__SENTRY_WRAPPING_TARGET__'; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index b79243627432..0f8ee9d5c9c6 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -164,7 +164,7 @@ export type EntryPointObject = { import: string | Array }; */ export type WebpackModuleRule = { - test?: string | RegExp; + test?: string | RegExp | ((resourcePath: string) => boolean); include?: Array | RegExp; exclude?: (filepath: string) => boolean; use?: ModuleRuleUseProperty | Array; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index b5a8d07db2fd..b3281b3ac020 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -99,23 +99,61 @@ export function constructWebpackConfigFunction( if (isServer) { if (userSentryOptions.autoInstrumentServerFunctions !== false) { - const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string; + let pagesDirPath: string; + if ( + fs.existsSync(path.join(projectDir, 'pages')) && + fs.lstatSync(path.join(projectDir, 'pages')).isDirectory() + ) { + pagesDirPath = path.join(projectDir, 'pages'); + } else { + pagesDirPath = path.join(projectDir, 'src', 'pages'); + } + + const middlewareJsPath = path.join(pagesDirPath, '..', 'middleware.js'); + const middlewareTsPath = path.join(pagesDirPath, '..', 'middleware.ts'); // Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; + const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`); const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|'); // 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. newConfig.module.rules.unshift({ - test: new RegExp(`^${escapeStringForRegex(pagesDir)}.*\\.(${pageExtensionRegex})$`), + test: resourcePath => { + // We generally want to apply the loader to all API routes, pages and to the middleware file. + + // `resourcePath` may be an absolute path or a path relative to the context of the webpack config + let absoluteResourcePath: string; + if (path.isAbsolute(resourcePath)) { + absoluteResourcePath = resourcePath; + } else { + absoluteResourcePath = path.join(projectDir, resourcePath); + } + const normalizedAbsoluteResourcePath = path.normalize(absoluteResourcePath); + + if ( + // Match everything inside pages/ with the appropriate file extension + normalizedAbsoluteResourcePath.startsWith(pagesDirPath) && + dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext)) + ) { + return true; + } else if ( + // Match middleware.js and middleware.ts + normalizedAbsoluteResourcePath === middlewareJsPath || + normalizedAbsoluteResourcePath === middlewareTsPath + ) { + return true; + } else { + return false; + } + }, use: [ { loader: path.resolve(__dirname, 'loaders/wrappingLoader.js'), options: { - pagesDir, + pagesDir: pagesDirPath, pageExtensionRegex, excludeServerRoutes: userSentryOptions.excludeServerRoutes, - isEdgeRuntime: buildContext.nextRuntime === 'edge', }, }, ],