diff --git a/packages/nextjs/rollup.npm.config.js b/packages/nextjs/rollup.npm.config.js index 9d560679b5ce..63f420f466ad 100644 --- a/packages/nextjs/rollup.npm.config.js +++ b/packages/nextjs/rollup.npm.config.js @@ -11,7 +11,7 @@ export default [ 'src/client/index.ts', 'src/server/index.ts', 'src/edge/index.ts', - 'src/config/webpack.ts', + 'src/config/index.ts', ], // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because @@ -25,6 +25,7 @@ export default [ 'src/config/templates/pageWrapperTemplate.ts', 'src/config/templates/apiWrapperTemplate.ts', 'src/config/templates/middlewareWrapperTemplate.ts', + 'src/config/templates/serverComponentWrapperTemplate.ts', ], packageSpecificConfig: { diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index fbd9e1eaefba..8537c15cb963 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -114,3 +114,5 @@ export { withSentryServerSideErrorGetInitialProps, wrapErrorGetInitialPropsWithSentry, } from './wrapErrorGetInitialPropsWithSentry'; + +export { wrapAppDirComponentWithSentry } from './wrapAppDirComponentWithSentry'; diff --git a/packages/nextjs/src/client/wrapAppDirComponentWithSentry.ts b/packages/nextjs/src/client/wrapAppDirComponentWithSentry.ts new file mode 100644 index 000000000000..767f2beb5be7 --- /dev/null +++ b/packages/nextjs/src/client/wrapAppDirComponentWithSentry.ts @@ -0,0 +1,7 @@ +/** + * Currently just a pass-through to provide isomorphism for the client. May be used in the future to add instrumentation + * for client components. + */ +export function wrapAppDirComponentWithSentry(wrappingTarget: any): any { + return wrappingTarget; +} diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 1e7aac4b7c8a..eba33dfb7ac3 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -15,6 +15,14 @@ const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encod const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js'); const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' }); +const serverComponentWrapperTemplatePath = path.resolve( + __dirname, + '..', + 'templates', + 'serverComponentWrapperTemplate.js', +); +const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' }); + // Just a simple placeholder to make referencing module consistent const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module'; @@ -23,8 +31,10 @@ const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs'; type LoaderOptions = { pagesDir: string; + appDir: string; pageExtensionRegex: string; excludeServerRoutes: Array; + wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'page-server-component'; }; /** @@ -36,52 +46,91 @@ export default function wrappingLoader( this: LoaderThis, userCode: string, userModuleSourceMap: any, -): void | string { +): void { // We know one or the other will be defined, depending on the version of webpack being used const { pagesDir, + appDir, pageExtensionRegex, excludeServerRoutes = [], + wrappingTargetKind, } = 'getOptions' in this ? this.getOptions() : this.query; this.async(); - // Get the parameterized route name from this page's filepath - const parameterizedRoute = path - // Get the path of the file insde of the pages directory - .relative(pagesDir, this.resourcePath) - // Add a slash at the beginning - .replace(/(.*)/, '/$1') - // Pull off the file extension - .replace(new RegExp(`\\.(${pageExtensionRegex})`), '') - // Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into - // just `/xyz` - .replace(/\/index$/, '') - // In case all of the above have left us with an empty string (which will happen if we're dealing with the - // homepage), sub back in the root route - .replace(/^$/, '/'); - - // Skip explicitly-ignored pages - if (stringMatchesSomePattern(parameterizedRoute, excludeServerRoutes, true)) { - this.callback(null, userCode, userModuleSourceMap); - return; - } + let templateCode: string; - const middlewareJsPath = path.join(pagesDir, '..', 'middleware.js'); - const middlewareTsPath = path.join(pagesDir, '..', 'middleware.ts'); + if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') { + // Get the parameterized route name from this page's filepath + const parameterizedPagesRoute = path.posix + .normalize( + path + // Get the path of the file insde of the pages directory + .relative(pagesDir, this.resourcePath), + ) + // Add a slash at the beginning + .replace(/(.*)/, '/$1') + // Pull off the file extension + .replace(new RegExp(`\\.(${pageExtensionRegex})`), '') + // Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into + // just `/xyz` + .replace(/\/index$/, '') + // In case all of the above have left us with an empty string (which will happen if we're dealing with the + // homepage), sub back in the root route + .replace(/^$/, '/'); - let templateCode: string; - if (parameterizedRoute.startsWith('/api')) { - templateCode = apiWrapperTemplateCode; - } else if (this.resourcePath === middlewareJsPath || this.resourcePath === middlewareTsPath) { + // Skip explicitly-ignored pages + if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) { + this.callback(null, userCode, userModuleSourceMap); + return; + } + + if (wrappingTargetKind === 'page') { + templateCode = pageWrapperTemplateCode; + } else if (wrappingTargetKind === 'api-route') { + templateCode = apiWrapperTemplateCode; + } else { + throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); + } + + // Inject the route and the path to the file we're wrapping into the template + templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\')); + } else if (wrappingTargetKind === 'page-server-component') { + // Get the parameterized route name from this page's filepath + const parameterizedPagesRoute = path.posix + .normalize(path.relative(appDir, this.resourcePath)) + // Add a slash at the beginning + .replace(/(.*)/, '/$1') + // Pull off the file name + .replace(/\/page\.(js|jsx|tsx)$/, '') + // Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts + .replace(/\/(\(.*?\)\/)+/g, '/') + // In case all of the above have left us with an empty string (which will happen if we're dealing with the + // homepage), sub back in the root route + .replace(/^$/, '/'); + + // Skip explicitly-ignored pages + if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) { + this.callback(null, userCode, userModuleSourceMap); + return; + } + + // The following string is what Next.js injects in order to mark client components: + // https://github.com/vercel/next.js/blob/295f9da393f7d5a49b0c2e15a2f46448dbdc3895/packages/next/build/analysis/get-page-static-info.ts#L37 + // https://github.com/vercel/next.js/blob/a1c15d84d906a8adf1667332a3f0732be615afa0/packages/next-swc/crates/core/src/react_server_components.rs#L247 + // We do not want to wrap client components + if (userCode.includes('/* __next_internal_client_entry_do_not_use__ */')) { + this.callback(null, userCode, userModuleSourceMap); + return; + } + + templateCode = serverComponentWrapperTemplateCode; + } else if (wrappingTargetKind === 'middleware') { templateCode = middlewareWrapperTemplateCode; } else { - templateCode = pageWrapperTemplateCode; + throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); } - // Inject the route and the path to the file we're wrapping into the template - templateCode = templateCode.replace(/__ROUTE__/g, parameterizedRoute.replace(/\\/g, '\\\\')); - // Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand. templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME); @@ -97,7 +146,6 @@ export default function wrappingLoader( `[@sentry/nextjs] Could not instrument ${this.resourcePath}. An error occurred while auto-wrapping:\n${err}`, ); this.callback(null, userCode, userModuleSourceMap); - return; }); } diff --git a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts new file mode 100644 index 000000000000..4ba89238aad8 --- /dev/null +++ b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts @@ -0,0 +1,36 @@ +/* + * This file is a template for the code which will be substituted when our webpack loader handles non-API files in the + * `pages/` directory. + * + * We use `__SENTRY_WRAPPING_TARGET_FILE__` 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 wrapee from '__SENTRY_WRAPPING_TARGET_FILE__'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as Sentry from '@sentry/nextjs'; + +type ServerComponentModule = { + default: unknown; +}; + +const serverComponentModule = wrapee as ServerComponentModule; + +const serverComponent = serverComponentModule.default; + +let wrappedServerComponent; +if (typeof serverComponent === 'function') { + wrappedServerComponent = Sentry.wrapAppDirComponentWithSentry(serverComponent); +} else { + wrappedServerComponent = serverComponent; +} + +// 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_FILE__'; + +export default wrappedServerComponent; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index dd7a18f2f821..721b6d2ffb58 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -106,9 +106,14 @@ export type UserSentryOptions = { */ autoInstrumentMiddleware?: boolean; + /** + * Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`. + */ + autoInstrumentAppDirectory?: boolean; + /** * Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of - * strings or regular expressions. + * strings or regular expressions. This options also affects pages in the `app` directory. * * NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths * (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full, diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 918d98f74cb4..46a6c9e5ec72 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -86,59 +86,128 @@ export function constructWebpackConfigFunction( ], }); - if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) { - let pagesDirPath: string; - if (fs.existsSync(path.join(projectDir, 'pages')) && fs.lstatSync(path.join(projectDir, 'pages')).isDirectory()) { - pagesDirPath = path.join(projectDir, 'pages'); + let pagesDirPath: string; + let appDirPath: string; + if (fs.existsSync(path.join(projectDir, 'pages')) && fs.lstatSync(path.join(projectDir, 'pages')).isDirectory()) { + pagesDirPath = path.join(projectDir, 'pages'); + appDirPath = path.join(projectDir, 'app'); + } else { + pagesDirPath = path.join(projectDir, 'src', 'pages'); + appDirPath = path.join(projectDir, 'src', 'app'); + } + + const apiRoutesPath = path.join(pagesDirPath, 'api'); + + 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('|'); + + const staticWrappingLoaderOptions = { + appDir: appDirPath, + pagesDir: pagesDirPath, + pageExtensionRegex, + excludeServerRoutes: userSentryOptions.excludeServerRoutes, + }; + + const normalizeLoaderResourcePath = (resourcePath: string): string => { + // `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 { - pagesDirPath = path.join(projectDir, 'src', 'pages'); + absoluteResourcePath = path.join(projectDir, resourcePath); } - const middlewareJsPath = path.join(pagesDirPath, '..', 'middleware.js'); - const middlewareTsPath = path.join(pagesDirPath, '..', 'middleware.ts'); + return path.normalize(absoluteResourcePath); + }; - // 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('|'); + if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) { + // It is very important that we insert our loaders at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened. - // 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. + // Wrap pages newConfig.module.rules.unshift({ 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 + const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); + return ( normalizedAbsoluteResourcePath.startsWith(pagesDirPath) && + !normalizedAbsoluteResourcePath.startsWith(apiRoutesPath) && dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext)) - ) { - return true; - } else if ( - // Match middleware.js and middleware.ts - normalizedAbsoluteResourcePath === middlewareJsPath || - normalizedAbsoluteResourcePath === middlewareTsPath - ) { - return userSentryOptions.autoInstrumentMiddleware ?? true; - } else { - return false; - } + ); + }, + use: [ + { + loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + options: { + ...staticWrappingLoaderOptions, + wrappingTargetKind: 'page', + }, + }, + ], + }); + + // Wrap api routes + newConfig.module.rules.unshift({ + test: resourcePath => { + const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); + return ( + normalizedAbsoluteResourcePath.startsWith(apiRoutesPath) && + dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext)) + ); + }, + use: [ + { + loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + options: { + ...staticWrappingLoaderOptions, + wrappingTargetKind: 'api-route', + }, + }, + ], + }); + + // Wrap middleware + newConfig.module.rules.unshift({ + test: resourcePath => { + const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); + return ( + normalizedAbsoluteResourcePath === middlewareJsPath || normalizedAbsoluteResourcePath === middlewareTsPath + ); + }, + use: [ + { + loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + options: { + ...staticWrappingLoaderOptions, + wrappingTargetKind: 'middleware', + }, + }, + ], + }); + } + + if (isServer && userSentryOptions.autoInstrumentAppDirectory !== false) { + // Wrap page server components + newConfig.module.rules.unshift({ + test: resourcePath => { + const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); + + // ".js, .jsx, or .tsx file extensions can be used for Pages" + // https://beta.nextjs.org/docs/routing/pages-and-layouts#pages:~:text=.js%2C%20.jsx%2C%20or%20.tsx%20file%20extensions%20can%20be%20used%20for%20Pages. + return ( + normalizedAbsoluteResourcePath.startsWith(appDirPath) && + !!normalizedAbsoluteResourcePath.match(/[\\/]page\.(js|jsx|tsx)$/) + ); }, use: [ { loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), options: { - pagesDir: pagesDirPath, - pageExtensionRegex, - excludeServerRoutes: userSentryOptions.excludeServerRoutes, + ...staticWrappingLoaderOptions, + wrappingTargetKind: 'page-server-component', }, }, ], diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 712b0f61a81f..9bfd7046474f 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -139,3 +139,5 @@ export { } from './wrapApiHandlerWithSentry'; export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry'; + +export { wrapAppDirComponentWithSentry } from './wrapAppDirComponentWithSentry'; diff --git a/packages/nextjs/src/edge/wrapAppDirComponentWithSentry.ts b/packages/nextjs/src/edge/wrapAppDirComponentWithSentry.ts new file mode 100644 index 000000000000..116df75a4caa --- /dev/null +++ b/packages/nextjs/src/edge/wrapAppDirComponentWithSentry.ts @@ -0,0 +1,32 @@ +import { captureException } from '@sentry/core'; + +/** + * Wraps an `app` directory server component with Sentry error instrumentation. + */ +export function wrapAppDirComponentWithSentry any>(appDirComponent: F): F { + // Even though users may define server components as async functions, for the client bundles + // Next.js will turn them into synchronous functions and it will transform any`await`s into instances of the`use` + // hook. 🤯 + return new Proxy(appDirComponent, { + apply: (originalFunction, thisArg, args) => { + let maybePromiseResult; + + try { + maybePromiseResult = originalFunction.apply(thisArg, args); + } catch (e) { + captureException(e); + throw e; + } + + if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return maybePromiseResult.then(null, (e: Error) => { + captureException(e); + throw e; + }); + } else { + return maybePromiseResult; + } + }, + }); +} diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 58c8ac33da29..aca9f9d48211 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -125,3 +125,8 @@ export declare function wrapErrorGetInitialPropsWithSentry any>( getInitialProps: F, ): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; + +/** + * Wraps an `app` directory component with Sentry error instrumentation. (Currently only reports errors for server components) + */ +export declare function wrapAppDirComponentWithSentry any>(WrappingTarget: F): F; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 94404082b09a..9afdedd9ae91 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -219,3 +219,5 @@ export { withSentryAPI, wrapApiHandlerWithSentry, } from './wrapApiHandlerWithSentry'; + +export { wrapAppDirComponentWithSentry } from './wrapAppDirComponentWithSentry'; diff --git a/packages/nextjs/src/server/wrapAppDirComponentWithSentry.ts b/packages/nextjs/src/server/wrapAppDirComponentWithSentry.ts new file mode 100644 index 000000000000..116df75a4caa --- /dev/null +++ b/packages/nextjs/src/server/wrapAppDirComponentWithSentry.ts @@ -0,0 +1,32 @@ +import { captureException } from '@sentry/core'; + +/** + * Wraps an `app` directory server component with Sentry error instrumentation. + */ +export function wrapAppDirComponentWithSentry any>(appDirComponent: F): F { + // Even though users may define server components as async functions, for the client bundles + // Next.js will turn them into synchronous functions and it will transform any`await`s into instances of the`use` + // hook. 🤯 + return new Proxy(appDirComponent, { + apply: (originalFunction, thisArg, args) => { + let maybePromiseResult; + + try { + maybePromiseResult = originalFunction.apply(thisArg, args); + } catch (e) { + captureException(e); + throw e; + } + + if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return maybePromiseResult.then(null, (e: Error) => { + captureException(e); + throw e; + }); + } else { + return maybePromiseResult; + } + }, + }); +} diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index 714d3388782c..9c56f97e5b3b 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -1,6 +1,7 @@ // mock helper functions not tested directly in this file import './mocks'; +import type { ModuleRuleUseProperty, WebpackModuleRule } from '../../src/config/types'; import { clientBuildContext, clientWebpackConfig, @@ -38,6 +39,29 @@ declare global { } } +function applyRuleToResource(rule: WebpackModuleRule, resourcePath: string): ModuleRuleUseProperty[] { + const applications = []; + + let shouldApply: boolean = false; + if (typeof rule.test === 'function') { + shouldApply = rule.test(resourcePath); + } else if (rule.test instanceof RegExp) { + shouldApply = !!resourcePath.match(rule.test); + } else if (rule.test) { + shouldApply = resourcePath === rule.test; + } + + if (shouldApply) { + if (Array.isArray(rule.use)) { + applications.push(...rule.use); + } else if (rule.use) { + applications.push(rule.use); + } + } + + return applications; +} + describe('webpack loaders', () => { describe('server loaders', () => { it('adds server `valueInjection` loader to server config', async () => { @@ -60,6 +84,128 @@ describe('webpack loaders', () => { ], }); }); + + it.each([ + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/testPage.tsx', + expectedWrappingTargetKind: 'page', + }, + { + resourcePath: './src/pages/testPage.tsx', + expectedWrappingTargetKind: 'page', + }, + { + resourcePath: './pages/testPage.tsx', + expectedWrappingTargetKind: undefined, + }, + { + resourcePath: '../src/pages/testPage.tsx', + expectedWrappingTargetKind: undefined, + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/nested/testPage.ts', + expectedWrappingTargetKind: 'page', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/nested/testPage.js', + expectedWrappingTargetKind: 'page', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/[nested]/[testPage].js', + expectedWrappingTargetKind: 'page', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/[...testPage].js', + expectedWrappingTargetKind: 'page', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.js', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: './src/middleware.js', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: './middleware.js', + expectedWrappingTargetKind: undefined, + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.tsx', + expectedWrappingTargetKind: undefined, + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts', + expectedWrappingTargetKind: 'api-route', + }, + { + resourcePath: './src/pages/api/testApiRoute.ts', + expectedWrappingTargetKind: 'api-route', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/nested/testApiRoute.js', + expectedWrappingTargetKind: 'api-route', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/page.js', + expectedWrappingTargetKind: 'page-server-component', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/page.js', + expectedWrappingTargetKind: 'page-server-component', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/page.ts', // ts is not a valid file ending for pages in the app dir + expectedWrappingTargetKind: undefined, + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/(group)/nested/page.tsx', + expectedWrappingTargetKind: 'page-server-component', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/(group)/nested/loading.ts', + expectedWrappingTargetKind: undefined, + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/layout.js', + expectedWrappingTargetKind: undefined, + }, + ])( + 'should apply the right wrappingTargetKind with wrapping loader ($resourcePath)', + async ({ resourcePath, expectedWrappingTargetKind }) => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + }); + + const loaderApplications: ModuleRuleUseProperty[] = []; + finalWebpackConfig.module.rules.forEach(rule => { + loaderApplications.push(...applyRuleToResource(rule, resourcePath)); + }); + + if (expectedWrappingTargetKind) { + expect(loaderApplications).toContainEqual( + expect.objectContaining({ + loader: expect.stringMatching(/wrappingLoader\.js$/), + options: expect.objectContaining({ + wrappingTargetKind: expectedWrappingTargetKind, + }), + }), + ); + } else { + expect(loaderApplications).not.toContainEqual( + expect.objectContaining({ + loader: expect.stringMatching(/wrappingLoader\.js$/), + }), + ); + } + }, + ); }); describe('client loaders', () => {