diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index e0c60e4484e8..8c0c39f1ef04 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -22,6 +22,9 @@ export type NextConfigObject = { disableClientWebpackPlugin?: boolean; hideSourceMaps?: boolean; + // Force webpack to apply the same transpilation rules to the SDK code as apply to user code. Helpful when targeting + // older browsers which don't support ES6 (or ES6+ features like object spread). + transpileClientSDK?: boolean; // Upload files from `/static/chunks` rather than `/static/chunks/pages`. Usually files outside of // `pages/` only contain third-party code, but in cases where they contain user code, restricting the webpack // plugin's upload breaks sourcemaps for those user-code-containing files, because it keeps them from being @@ -57,13 +60,7 @@ export type WebpackConfigObject = { alias?: { [key: string]: string | boolean }; }; module?: { - rules: Array<{ - test: string | RegExp; - use: Array<{ - loader: string; - options: Record; - }>; - }>; + rules: Array; }; } & { // other webpack options @@ -98,3 +95,20 @@ export type EntryPropertyFunction = () => Promise; // listed under the key `import`. export type EntryPointValue = string | Array | EntryPointObject; export type EntryPointObject = { import: string | Array }; + +/** + * Webpack `module.rules` entry + */ + +export type WebpackModuleRule = { + test?: string | RegExp; + include?: Array | RegExp; + exclude?: (filepath: string) => boolean; + use?: ModuleRuleUseProperty | Array; + oneOf?: Array; +}; + +export type ModuleRuleUseProperty = { + loader?: string; + options?: Record; +}; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 5f98421b8d1b..972fbf75351c 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -13,6 +13,7 @@ import { WebpackConfigFunction, WebpackConfigObject, WebpackEntryProperty, + WebpackModuleRule, } from './types'; export { SentryWebpackPlugin }; @@ -41,7 +42,7 @@ export function constructWebpackConfigFunction( // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that // `incomingConfig` and `buildContext` are referred to as `config` and `options` in the nextjs docs. const newWebpackFunction = (incomingConfig: WebpackConfigObject, buildContext: BuildContext): WebpackConfigObject => { - const { isServer, dev: isDev } = buildContext; + const { isServer, dev: isDev, dir: projectDir } = buildContext; let newConfig = { ...incomingConfig }; // if user has custom webpack config (which always takes the form of a function), run it so we have actual values to @@ -73,6 +74,34 @@ export function constructWebpackConfigFunction( }; } + // The SDK uses syntax (ES6 and ES6+ features like object spread) which isn't supported by older browsers. For users + // who want to support such browsers, `transpileClientSDK` allows them to force the SDK code to go through the same + // transpilation that their code goes through. We don't turn this on by default because it increases bundle size + // fairly massively. + if (!isServer && userNextConfig.sentry?.transpileClientSDK) { + // Find all loaders which apply transpilation to user code + const transpilationRules = findTranspilationRules(newConfig.module?.rules, projectDir); + + // For each matching rule, wrap its `exclude` function so that it won't exclude SDK files, even though they're in + // `node_modules` (which is otherwise excluded) + transpilationRules.forEach(rule => { + // All matching rules will necessarily have an `exclude` property, but this keeps TS happy + if (rule.exclude && typeof rule.exclude === 'function') { + const origExclude = rule.exclude; + + const newExclude = (filepath: string): boolean => { + if (filepath.includes('@sentry')) { + // `false` in this case means "don't exclude it" + return false; + } + return origExclude(filepath); + }; + + rule.exclude = newExclude; + } + }); + } + // Tell webpack to inject user config files (containing the two `Sentry.init()` calls) into the appropriate output // bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do // this, we'll have a statement of the form `x.y = () => f(x.y)`, where one of the things `f` does is call `x.y`. @@ -124,6 +153,72 @@ export function constructWebpackConfigFunction( return newWebpackFunction; } +/** + * Determine if this `module.rules` entry is one which will transpile user code + * + * @param rule The rule to check + * @param projectDir The path to the user's project directory + * @returns True if the rule transpiles user code, and false otherwise + */ +function isMatchingRule(rule: WebpackModuleRule, projectDir: string): boolean { + // We want to run our SDK code through the same transformations the user's code will go through, so we test against a + // sample user code path + const samplePagePath = path.resolve(projectDir, 'pageFile.js'); + if (rule.test && rule.test instanceof RegExp && !rule.test.test(samplePagePath)) { + return false; + } + if (Array.isArray(rule.include) && !rule.include.includes(projectDir)) { + return false; + } + + // `rule.use` can be an object or an array of objects. For simplicity, force it to be an array. + const useEntries = Array.isArray(rule.use) ? rule.use : [rule.use]; + + // Depending on the version of nextjs we're talking about, the loader which does the transpiling is either + // + // 'next-babel-loader' (next 10), + // '/abs/path/to/node_modules/next/more/path/babel/even/more/path/loader/yet/more/path/index.js' (next 11), or + // 'next-swc-loader' (next 12). + // + // The next 11 option is ugly, but thankfully 'next', 'babel', and 'loader' do appear in it in the same order as in + // 'next-babel-loader', so we can use the same regex to test for both. + if (!useEntries.some(entry => entry?.loader && new RegExp('next.*(babel|swc).*loader').test(entry.loader))) { + return false; + } + + return true; +} + +/** + * Find all rules in `module.rules` which transpile user code. + * + * @param rules The `module.rules` value + * @param projectDir The path to the user's project directory + * @returns An array of matching rules + */ +function findTranspilationRules(rules: WebpackModuleRule[] | undefined, projectDir: string): WebpackModuleRule[] { + if (!rules) { + return []; + } + + const matchingRules: WebpackModuleRule[] = []; + + // Each entry in `module.rules` is either a rule in and of itself or an object with a `oneOf` property, whose value is + // an array of rules + rules.forEach(rule => { + // if (rule.oneOf) { + if (isMatchingRule(rule, projectDir)) { + matchingRules.push(rule); + } else if (rule.oneOf) { + const matchingOneOfRules = rule.oneOf.filter(oneOfRule => isMatchingRule(oneOfRule, projectDir)); + matchingRules.push(...matchingOneOfRules); + // } else if (isMatchingRule(rule, projectDir)) { + } + }); + + return matchingRules; +} + /** * Modify the webpack `entry` property so that the code in `sentry.server.config.js` and `sentry.client.config.js` is * included in the the necessary bundles.