Skip to content

Commit 22df479

Browse files
authored
fix(nextjs): Add transpileClientSDK option (#5472)
As of version 7.0, our SDK publishes code which isn't natively compatible with older browsers (ones which can't handle ES6 or certain ES6+ features like object spread). If users control the build process of their apps, they can make sure that our SDK code is included in the transpilation they apply to their own code. In nextjs, however, the build process is controlled by the framework, and while users _can_ make modifications to it, they're modifying a webpack config they didn't create, making it a more challenging task. Fortunately, our SDK can also modify the build process, and that's what this PR does. Nextjs does its transpiling using a loader (either `next-babel-loader` or `next-swc-loader`, depending on the nextjs version), controlled by entries in the `module.rules` section of the webpack config. Normally this transpiling excludes all of `node_modules`, but we can make it not ignore the SDK by wrapping the `exclude` function so that it checks first for SDK code (and doesn't exclude it), before applying the normal checks. Notes: - In order to do the wrapping, we first have to find the correct `module.rules` entries. The default value of `module.rules` varies by nextjs version, so instead of looking in a known location, we look at all entries. In order to determine which ones to modify, we match on `test` (a regex for the type of file upon which the rule acts), `include` (a list of paths upon which the rule will act), and `loader` (the name of the module actually doing the transpiling). Any rule which would apply `next-babel-loader` or `next-swc-loader` to user files gets modified to also apply the loader to SDK code. - Because this is only a browser concern, this modification is only made during the client-side build. - This only applies to folks trying to support older browsers, and it noticeably increases bundle size, so it's an opt-in process, controlled by a new `next.config.js` option called `transpileClientSDK`. - While it would theoretically be possible to figure out which builds need this (someone with `target: 'es5'` in their `tsconfig` would be a good candidate, for example), the number of locations and ways in which a user can configure that is prohibitively large (a tsconfig with the default name, a tsconfig with a configured name, babel config in webpack config, babel config in `package.json`, babel config in a file with any one of [nine possible names](https://babeljs.io/docs/en/config-files), using a babel preset, using any of a number of different `target` values, and on and on and on). The option is thus only enabled if a user does so directly. - There will be a follow-up docs PR once this option is released in the SDK. This addresses part of #5452. Non-nextjs users will need to do a similar adjustment on their own, which we will also need to document.
1 parent 96255d4 commit 22df479

File tree

2 files changed

+117
-8
lines changed

2 files changed

+117
-8
lines changed

packages/nextjs/src/config/types.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export type NextConfigObject = {
2222
disableClientWebpackPlugin?: boolean;
2323
hideSourceMaps?: boolean;
2424

25+
// Force webpack to apply the same transpilation rules to the SDK code as apply to user code. Helpful when targeting
26+
// older browsers which don't support ES6 (or ES6+ features like object spread).
27+
transpileClientSDK?: boolean;
2528
// Upload files from `<distDir>/static/chunks` rather than `<distDir>/static/chunks/pages`. Usually files outside of
2629
// `pages/` only contain third-party code, but in cases where they contain user code, restricting the webpack
2730
// plugin's upload breaks sourcemaps for those user-code-containing files, because it keeps them from being
@@ -57,13 +60,7 @@ export type WebpackConfigObject = {
5760
alias?: { [key: string]: string | boolean };
5861
};
5962
module?: {
60-
rules: Array<{
61-
test: string | RegExp;
62-
use: Array<{
63-
loader: string;
64-
options: Record<string, unknown>;
65-
}>;
66-
}>;
63+
rules: Array<WebpackModuleRule>;
6764
};
6865
} & {
6966
// other webpack options
@@ -98,3 +95,20 @@ export type EntryPropertyFunction = () => Promise<EntryPropertyObject>;
9895
// listed under the key `import`.
9996
export type EntryPointValue = string | Array<string> | EntryPointObject;
10097
export type EntryPointObject = { import: string | Array<string> };
98+
99+
/**
100+
* Webpack `module.rules` entry
101+
*/
102+
103+
export type WebpackModuleRule = {
104+
test?: string | RegExp;
105+
include?: Array<string | RegExp> | RegExp;
106+
exclude?: (filepath: string) => boolean;
107+
use?: ModuleRuleUseProperty | Array<ModuleRuleUseProperty>;
108+
oneOf?: Array<WebpackModuleRule>;
109+
};
110+
111+
export type ModuleRuleUseProperty = {
112+
loader?: string;
113+
options?: Record<string, unknown>;
114+
};

packages/nextjs/src/config/webpack.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
WebpackConfigFunction,
1414
WebpackConfigObject,
1515
WebpackEntryProperty,
16+
WebpackModuleRule,
1617
} from './types';
1718

1819
export { SentryWebpackPlugin };
@@ -41,7 +42,7 @@ export function constructWebpackConfigFunction(
4142
// we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that
4243
// `incomingConfig` and `buildContext` are referred to as `config` and `options` in the nextjs docs.
4344
const newWebpackFunction = (incomingConfig: WebpackConfigObject, buildContext: BuildContext): WebpackConfigObject => {
44-
const { isServer, dev: isDev } = buildContext;
45+
const { isServer, dev: isDev, dir: projectDir } = buildContext;
4546
let newConfig = { ...incomingConfig };
4647

4748
// 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(
7374
};
7475
}
7576

77+
// The SDK uses syntax (ES6 and ES6+ features like object spread) which isn't supported by older browsers. For users
78+
// who want to support such browsers, `transpileClientSDK` allows them to force the SDK code to go through the same
79+
// transpilation that their code goes through. We don't turn this on by default because it increases bundle size
80+
// fairly massively.
81+
if (!isServer && userNextConfig.sentry?.transpileClientSDK) {
82+
// Find all loaders which apply transpilation to user code
83+
const transpilationRules = findTranspilationRules(newConfig.module?.rules, projectDir);
84+
85+
// For each matching rule, wrap its `exclude` function so that it won't exclude SDK files, even though they're in
86+
// `node_modules` (which is otherwise excluded)
87+
transpilationRules.forEach(rule => {
88+
// All matching rules will necessarily have an `exclude` property, but this keeps TS happy
89+
if (rule.exclude && typeof rule.exclude === 'function') {
90+
const origExclude = rule.exclude;
91+
92+
const newExclude = (filepath: string): boolean => {
93+
if (filepath.includes('@sentry')) {
94+
// `false` in this case means "don't exclude it"
95+
return false;
96+
}
97+
return origExclude(filepath);
98+
};
99+
100+
rule.exclude = newExclude;
101+
}
102+
});
103+
}
104+
76105
// Tell webpack to inject user config files (containing the two `Sentry.init()` calls) into the appropriate output
77106
// bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do
78107
// 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(
124153
return newWebpackFunction;
125154
}
126155

156+
/**
157+
* Determine if this `module.rules` entry is one which will transpile user code
158+
*
159+
* @param rule The rule to check
160+
* @param projectDir The path to the user's project directory
161+
* @returns True if the rule transpiles user code, and false otherwise
162+
*/
163+
function isMatchingRule(rule: WebpackModuleRule, projectDir: string): boolean {
164+
// We want to run our SDK code through the same transformations the user's code will go through, so we test against a
165+
// sample user code path
166+
const samplePagePath = path.resolve(projectDir, 'pageFile.js');
167+
if (rule.test && rule.test instanceof RegExp && !rule.test.test(samplePagePath)) {
168+
return false;
169+
}
170+
if (Array.isArray(rule.include) && !rule.include.includes(projectDir)) {
171+
return false;
172+
}
173+
174+
// `rule.use` can be an object or an array of objects. For simplicity, force it to be an array.
175+
const useEntries = Array.isArray(rule.use) ? rule.use : [rule.use];
176+
177+
// Depending on the version of nextjs we're talking about, the loader which does the transpiling is either
178+
//
179+
// 'next-babel-loader' (next 10),
180+
// '/abs/path/to/node_modules/next/more/path/babel/even/more/path/loader/yet/more/path/index.js' (next 11), or
181+
// 'next-swc-loader' (next 12).
182+
//
183+
// The next 11 option is ugly, but thankfully 'next', 'babel', and 'loader' do appear in it in the same order as in
184+
// 'next-babel-loader', so we can use the same regex to test for both.
185+
if (!useEntries.some(entry => entry?.loader && new RegExp('next.*(babel|swc).*loader').test(entry.loader))) {
186+
return false;
187+
}
188+
189+
return true;
190+
}
191+
192+
/**
193+
* Find all rules in `module.rules` which transpile user code.
194+
*
195+
* @param rules The `module.rules` value
196+
* @param projectDir The path to the user's project directory
197+
* @returns An array of matching rules
198+
*/
199+
function findTranspilationRules(rules: WebpackModuleRule[] | undefined, projectDir: string): WebpackModuleRule[] {
200+
if (!rules) {
201+
return [];
202+
}
203+
204+
const matchingRules: WebpackModuleRule[] = [];
205+
206+
// Each entry in `module.rules` is either a rule in and of itself or an object with a `oneOf` property, whose value is
207+
// an array of rules
208+
rules.forEach(rule => {
209+
// if (rule.oneOf) {
210+
if (isMatchingRule(rule, projectDir)) {
211+
matchingRules.push(rule);
212+
} else if (rule.oneOf) {
213+
const matchingOneOfRules = rule.oneOf.filter(oneOfRule => isMatchingRule(oneOfRule, projectDir));
214+
matchingRules.push(...matchingOneOfRules);
215+
// } else if (isMatchingRule(rule, projectDir)) {
216+
}
217+
});
218+
219+
return matchingRules;
220+
}
221+
127222
/**
128223
* Modify the webpack `entry` property so that the code in `sentry.server.config.js` and `sentry.client.config.js` is
129224
* included in the the necessary bundles.

0 commit comments

Comments
 (0)