Skip to content

Commit 1e78eb6

Browse files
authored
feat(nextjs): Parameterize prefix loader values (#6377)
This changes the `prefixLoader` we use in the nextjs SDK to accept template name and replacement values as parameters, and pulls injection of that loader into a helper function. It also renames the current template from `prefixLoaderTemplate` to `serverRewriteFramesPrefixLoaderTemplate`, to better indicate its purpose and to distinguish it from future templates also using the prefix loader.
1 parent da36b2a commit 1e78eb6

File tree

7 files changed

+149
-55
lines changed

7 files changed

+149
-55
lines changed

packages/nextjs/rollup.npm.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default [
2020
...makeNPMConfigVariants(
2121
makeBaseNPMConfig({
2222
entrypoints: [
23-
'src/config/templates/prefixLoaderTemplate.ts',
23+
'src/config/templates/serverRewriteFramesPrefixLoaderTemplate.ts',
2424
'src/config/templates/pageProxyLoaderTemplate.ts',
2525
'src/config/templates/apiProxyLoaderTemplate.ts',
2626
],
Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,38 @@
1+
import { escapeStringForRegex } from '@sentry/utils';
12
import * as fs from 'fs';
23
import * as path from 'path';
34

45
import { LoaderThis } from './types';
56

67
type LoaderOptions = {
7-
distDir: string;
8+
templatePrefix: string;
9+
replacements: Array<[string, string]>;
810
};
911

1012
/**
1113
* Inject templated code into the beginning of a module.
14+
*
15+
* Options:
16+
* - `templatePrefix`: The XXX in `XXXPrefixLoaderTemplate.ts`, to specify which template to use
17+
* - `replacements`: An array of tuples of the form `[<placeholder>, <replacementValue>]`, used for doing global
18+
* string replacement in the template. Note: The replacement is done sequentially, in the order in which the
19+
* replacement values are given. If any placeholder is a substring of any replacement value besides its own, make
20+
* sure to order the tuples in such a way as to avoid over-replacement.
1221
*/
1322
export default function prefixLoader(this: LoaderThis<LoaderOptions>, userCode: string): string {
1423
// We know one or the other will be defined, depending on the version of webpack being used
15-
const { distDir } = 'getOptions' in this ? this.getOptions() : this.query;
24+
const { templatePrefix, replacements } = 'getOptions' in this ? this.getOptions() : this.query;
1625

17-
const templatePath = path.resolve(__dirname, '../templates/prefixLoaderTemplate.js');
26+
const templatePath = path.resolve(__dirname, `../templates/${templatePrefix}PrefixLoaderTemplate.js`);
1827
// make sure the template is included when runing `webpack watch`
1928
this.addDependency(templatePath);
2029

21-
// Fill in the placeholder
30+
// Fill in placeholders
2231
let templateCode = fs.readFileSync(templatePath).toString();
23-
templateCode = templateCode.replace('__DIST_DIR__', distDir.replace(/\\/g, '\\\\'));
32+
replacements.forEach(([placeholder, value]) => {
33+
const placeholderRegex = new RegExp(escapeStringForRegex(placeholder), 'g');
34+
templateCode = templateCode.replace(placeholderRegex, value);
35+
});
2436

2537
return `${templateCode}\n${userCode}`;
2638
}

packages/nextjs/src/config/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export type WebpackConfigObject = {
100100
[key: string]: unknown;
101101
};
102102

103+
// A convenience type to save us from having to assert the existence of `module.rules` over and over
104+
export type WebpackConfigObjectWithModuleRules = WebpackConfigObject & Required<Pick<WebpackConfigObject, 'module'>>;
105+
103106
// Information about the current build environment
104107
export type BuildContext = {
105108
dev: boolean;

packages/nextjs/src/config/webpack.ts

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
UserSentryOptions,
2424
WebpackConfigFunction,
2525
WebpackConfigObject,
26+
WebpackConfigObjectWithModuleRules,
2627
WebpackEntryProperty,
2728
WebpackModuleRule,
2829
WebpackPluginInstance,
@@ -67,35 +68,21 @@ export function constructWebpackConfigFunction(
6768
buildContext: BuildContext,
6869
): WebpackConfigObject {
6970
const { isServer, dev: isDev, dir: projectDir } = buildContext;
70-
let newConfig = { ...incomingConfig };
71+
let rawNewConfig = { ...incomingConfig };
7172

7273
// if user has custom webpack config (which always takes the form of a function), run it so we have actual values to
7374
// work with
7475
if ('webpack' in userNextConfig && typeof userNextConfig.webpack === 'function') {
75-
newConfig = userNextConfig.webpack(newConfig, buildContext);
76+
rawNewConfig = userNextConfig.webpack(rawNewConfig, buildContext);
7677
}
7778

79+
// This mutates `rawNewConfig` in place, but also returns it in order to switch its type to one in which
80+
// `newConfig.module.rules` is required, so we don't have to keep asserting its existence
81+
const newConfig = setUpModuleRules(rawNewConfig);
82+
7883
if (isServer) {
79-
newConfig.module = {
80-
...newConfig.module,
81-
rules: [
82-
...(newConfig.module?.rules || []),
83-
{
84-
test: /sentry\.server\.config\.(jsx?|tsx?)/,
85-
use: [
86-
{
87-
// Support non-default output directories by making the output path (easy to get here at build-time)
88-
// available to the server SDK's default `RewriteFrames` instance (which needs it at runtime), by
89-
// injecting code to attach it to `global`.
90-
loader: path.resolve(__dirname, 'loaders/prefixLoader.js'),
91-
options: {
92-
distDir: userNextConfig.distDir || '.next',
93-
},
94-
},
95-
],
96-
},
97-
],
98-
};
84+
// This loader will inject code setting global values for use by `RewriteFrames`
85+
addRewriteFramesLoader(newConfig, 'server', userNextConfig);
9986

10087
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
10188
const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string;
@@ -628,3 +615,59 @@ function handleSourcemapHidingOptionWarning(userSentryOptions: UserSentryOptions
628615
// );
629616
// }
630617
}
618+
619+
/**
620+
* Ensure that `newConfig.module.rules` exists. Modifies the given config in place but also returns it in order to
621+
* change its type.
622+
*
623+
* @param newConfig A webpack config object which may or may not contain `module` and `module.rules`
624+
* @returns The same object, with an empty `module.rules` array added if necessary
625+
*/
626+
function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWithModuleRules {
627+
newConfig.module = {
628+
...newConfig.module,
629+
rules: [...(newConfig.module?.rules || [])],
630+
};
631+
// Surprising that we have to assert the type here, since we've demonstrably guaranteed the existence of
632+
// `newConfig.module.rules` just above, but ¯\_(ツ)_/¯
633+
return newConfig as WebpackConfigObjectWithModuleRules;
634+
}
635+
636+
/**
637+
* Support the `distDir` option by making its value (easy to get here at build-time) available to the server SDK's
638+
* default `RewriteFrames` instance (which needs it at runtime), by injecting code to attach it to `global`.
639+
*
640+
* @param newConfig The webpack config object being constructed
641+
* @param target Either 'server' or 'client'
642+
* @param userNextConfig The user's nextjs config options
643+
*/
644+
function addRewriteFramesLoader(
645+
newConfig: WebpackConfigObjectWithModuleRules,
646+
target: 'server' | 'client',
647+
userNextConfig: NextConfigObject,
648+
): void {
649+
const replacements = {
650+
server: [
651+
[
652+
'__DIST_DIR__',
653+
// Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape
654+
// characters)
655+
userNextConfig.distDir?.replace(/\\/g, '\\\\') || '.next',
656+
],
657+
],
658+
};
659+
660+
newConfig.module.rules.push({
661+
test: new RegExp(`sentry\\.${target}\\.config\\.(jsx?|tsx?)`),
662+
use: [
663+
{
664+
loader: path.resolve(__dirname, 'loaders/prefixLoader.js'),
665+
options: {
666+
templatePrefix: `${target}RewriteFrames`,
667+
// This weird cast will go away as soon as we add the client half of this function in
668+
replacements: replacements[target as 'server'],
669+
},
670+
},
671+
],
672+
});
673+
}

packages/nextjs/test/config/loaders.test.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,74 @@ import {
1010
} from './fixtures';
1111
import { materializeFinalWebpackConfig } from './testUtils';
1212

13+
type MatcherResult = { pass: boolean; message: () => string };
14+
15+
expect.extend({
16+
stringEndingWith(received: string, expectedEnding: string): MatcherResult {
17+
const failsTest = !received.endsWith(expectedEnding);
18+
const generateErrorMessage = () =>
19+
failsTest
20+
? // Regular error message for match failing
21+
`expected string ending with '${expectedEnding}', but got '${received}'`
22+
: // Error message for the match passing if someone has called it with `expect.not`
23+
`expected string not ending with '${expectedEnding}', but got '${received}'`;
24+
25+
return {
26+
pass: !failsTest,
27+
message: generateErrorMessage,
28+
};
29+
},
30+
});
31+
32+
declare global {
33+
// eslint-disable-next-line @typescript-eslint/no-namespace
34+
namespace jest {
35+
interface Expect {
36+
stringEndingWith: (expectedEnding: string) => MatcherResult;
37+
}
38+
}
39+
}
40+
1341
describe('webpack loaders', () => {
14-
it('adds loader to server config', async () => {
15-
const finalWebpackConfig = await materializeFinalWebpackConfig({
16-
exportedNextConfig,
17-
incomingWebpackConfig: serverWebpackConfig,
18-
incomingWebpackBuildContext: serverBuildContext,
42+
describe('server loaders', () => {
43+
it('adds server `RewriteFrames` loader to server config', async () => {
44+
const finalWebpackConfig = await materializeFinalWebpackConfig({
45+
exportedNextConfig,
46+
incomingWebpackConfig: serverWebpackConfig,
47+
incomingWebpackBuildContext: serverBuildContext,
48+
});
49+
50+
expect(finalWebpackConfig.module.rules).toContainEqual({
51+
test: /sentry\.server\.config\.(jsx?|tsx?)/,
52+
use: [
53+
{
54+
loader: expect.stringEndingWith('prefixLoader.js'),
55+
options: expect.objectContaining({ templatePrefix: 'serverRewriteFrames' }),
56+
},
57+
],
58+
});
1959
});
60+
});
61+
62+
describe('client loaders', () => {
63+
it("doesn't add `RewriteFrames` loader to client config", async () => {
64+
const finalWebpackConfig = await materializeFinalWebpackConfig({
65+
exportedNextConfig,
66+
incomingWebpackConfig: clientWebpackConfig,
67+
incomingWebpackBuildContext: clientBuildContext,
68+
});
2069

21-
expect(finalWebpackConfig.module!.rules).toEqual(
22-
expect.arrayContaining([
23-
{
24-
test: expect.any(RegExp),
70+
expect(finalWebpackConfig.module.rules).not.toContainEqual(
71+
expect.objectContaining({
2572
use: [
2673
{
27-
loader: expect.any(String),
28-
// Having no criteria for what the object contains is better than using `expect.any(Object)`, because that
29-
// could be anything
30-
options: expect.objectContaining({}),
74+
loader: expect.stringEndingWith('prefixLoader.js'),
75+
options: expect.objectContaining({ templatePrefix: expect.stringContaining('RewriteFrames') }),
3176
},
3277
],
33-
},
34-
]),
35-
);
36-
});
37-
38-
it("doesn't add loader to client config", async () => {
39-
const finalWebpackConfig = await materializeFinalWebpackConfig({
40-
exportedNextConfig,
41-
incomingWebpackConfig: clientWebpackConfig,
42-
incomingWebpackBuildContext: clientBuildContext,
78+
}),
79+
);
4380
});
44-
45-
expect(finalWebpackConfig.module).toBeUndefined();
4681
});
4782
});
4883

packages/nextjs/test/config/testUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
NextConfigObject,
88
SentryWebpackPluginOptions,
99
WebpackConfigObject,
10+
WebpackConfigObjectWithModuleRules,
1011
} from '../../src/config/types';
1112
import { constructWebpackConfigFunction, SentryWebpackPlugin } from '../../src/config/webpack';
1213
import { withSentryConfig } from '../../src/config/withSentryConfig';
@@ -57,7 +58,7 @@ export async function materializeFinalWebpackConfig(options: {
5758
userSentryWebpackPluginConfig?: Partial<SentryWebpackPluginOptions>;
5859
incomingWebpackConfig: WebpackConfigObject;
5960
incomingWebpackBuildContext: BuildContext;
60-
}): Promise<WebpackConfigObject> {
61+
}): Promise<WebpackConfigObjectWithModuleRules> {
6162
const { exportedNextConfig, userSentryWebpackPluginConfig, incomingWebpackConfig, incomingWebpackBuildContext } =
6263
options;
6364

@@ -83,7 +84,7 @@ export async function materializeFinalWebpackConfig(options: {
8384
const webpackEntryProperty = finalWebpackConfigValue.entry as EntryPropertyFunction;
8485
finalWebpackConfigValue.entry = await webpackEntryProperty();
8586

86-
return finalWebpackConfigValue;
87+
return finalWebpackConfigValue as WebpackConfigObjectWithModuleRules;
8788
}
8889

8990
// helper function to make sure we're checking the correct plugin's data

0 commit comments

Comments
 (0)