Skip to content

Commit 57c964a

Browse files
authored
ref(nextjs): Use loader to set RewriteFrames helper value (#5445)
In order to work, in the nextjs SDK our server-side `RewriteFrames` integration needs to know the name of the directory into which the app is built. While that information is readily available at build time, it isn't at runtime, so we adjust the webpack config to inject a global variable with the correct value in before the the injected `Sentry.init()` call from `sentry.server.config.js` wherever we inject that `Sentry.init(). Currently, this is done by creating a temporary file with the relevant code and adding it to the relevant pages' `entry` value along with `sentry.server.config.js`. This works, but has its downsides[1], and is a fair amount of machinery just to add a single line of code. This injects the same line of code using a loader, which is really just a transformation function for the code from matching files. In this case, we're modifying `sentry.server.config.js` itself, so that by the time it's added to various pages' entry points, it already contains the relevant code. Since we don't know what the value will be ahead of time, there's a template, which the loader then populates before injecting the new code. _But how is that any less machinery?_, you might ask. _All of that, still for just one line of code?_ Honestly, it's not. But in the quest to improve parameterization of transactions names, we're going to be adding a loader in any case. Adding the `RewriteFrames` value thus provides the perfect proof of concept, precisely because it's so simple, letting us get the loader working separate from all of the other changes that work will entail. Notes: - I moved setting the default value for `distDir` from retrieval-of-the-value time to storage-of-the-value time, because using a loader necessarily stringifies everything, meaning `undefined` was turning into the string `'undefined'`, preventing the default from getting picked up. - There are a few scattered lines of unrelated changes, artifacts of the fact that I ended up unpacking a few values at the top of the new webpack function. [1] #4813
1 parent fc7682c commit 57c964a

File tree

7 files changed

+154
-81
lines changed

7 files changed

+154
-81
lines changed

packages/nextjs/rollup.npm.config.js

+45-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,47 @@
11
import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js';
22

3-
export default makeNPMConfigVariants(
4-
makeBaseNPMConfig({
5-
// We need to include `instrumentServer.ts` separately because it's only conditionally required, and so rollup
6-
// doesn't automatically include it when calculating the module dependency tree.
7-
entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/utils/instrumentServer.ts'],
8-
// prevent this nextjs code from ending up in our built package (this doesn't happen automatially because the name
9-
// doesn't match an SDK dependency)
10-
packageSpecificConfig: { external: ['next/router'] },
11-
}),
12-
);
3+
export default [
4+
...makeNPMConfigVariants(
5+
makeBaseNPMConfig({
6+
// We need to include `instrumentServer.ts` separately because it's only conditionally required, and so rollup
7+
// doesn't automatically include it when calculating the module dependency tree.
8+
entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/utils/instrumentServer.ts'],
9+
10+
// prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because
11+
// the name doesn't match an SDK dependency)
12+
packageSpecificConfig: { external: ['next/router'] },
13+
}),
14+
),
15+
...makeNPMConfigVariants(
16+
makeBaseNPMConfig({
17+
entrypoints: ['src/config/prefixLoaderTemplate.ts'],
18+
19+
packageSpecificConfig: {
20+
output: {
21+
// preserve the original file structure (i.e., so that everything is still relative to `src`)
22+
entryFileNames: 'config/[name].js',
23+
24+
// this is going to be add-on code, so it doesn't need the trappings of a full module (and in fact actively
25+
// shouldn't have them, lest they muck with the module to which we're adding it)
26+
sourcemap: false,
27+
esModule: false,
28+
},
29+
},
30+
}),
31+
),
32+
...makeNPMConfigVariants(
33+
makeBaseNPMConfig({
34+
entrypoints: ['src/config/prefixLoader.ts'],
35+
36+
packageSpecificConfig: {
37+
output: {
38+
// make it so Rollup calms down about the fact that we're doing `export { loader as default }`
39+
exports: 'default',
40+
41+
// preserve the original file structure (i.e., so that everything is still relative to `src`)
42+
entryFileNames: 'config/[name].js',
43+
},
44+
},
45+
}),
46+
),
47+
];
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
type LoaderOptions = {
5+
distDir: string;
6+
};
7+
// TODO Use real webpack types
8+
type LoaderThis = {
9+
// Webpack 4
10+
query?: LoaderOptions;
11+
// Webpack 5
12+
getOptions?: () => LoaderOptions;
13+
addDependency: (filepath: string) => void;
14+
};
15+
16+
/**
17+
* Inject templated code into the beginning of a module.
18+
*/
19+
function prefixLoader(this: LoaderThis, userCode: string): string {
20+
// We know one or the other will be defined, depending on the version of webpack being used
21+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
22+
const { distDir } = this.getOptions ? this.getOptions() : this.query!;
23+
24+
const templatePath = path.resolve(__dirname, 'prefixLoaderTemplate.js');
25+
this.addDependency(templatePath);
26+
27+
// Fill in the placeholder
28+
let templateCode = fs.readFileSync(templatePath).toString();
29+
templateCode = templateCode.replace('__DIST_DIR__', distDir);
30+
31+
return `${templateCode}\n${userCode}`;
32+
}
33+
34+
export { prefixLoader as default };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
2+
(global as any).__rewriteFramesDistDir__ = '__DIST_DIR__';
3+
4+
// We need this to make this file an ESM module, which TS requires when using `isolatedModules`.
5+
export {};

packages/nextjs/src/config/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ export type WebpackConfigObject = {
5656
resolve?: {
5757
alias?: { [key: string]: string | boolean };
5858
};
59+
module?: {
60+
rules: Array<{
61+
test: string | RegExp;
62+
use: Array<{
63+
loader: string;
64+
options: Record<string, unknown>;
65+
}>;
66+
}>;
67+
};
5968
} & {
6069
// other webpack options
6170
[key: string]: unknown;

packages/nextjs/src/config/webpack.ts

+28-21
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { getSentryRelease } from '@sentry/node';
33
import { dropUndefinedKeys, logger } from '@sentry/utils';
44
import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin';
55
import * as fs from 'fs';
6-
import * as os from 'os';
76
import * as path from 'path';
87

98
import {
@@ -42,6 +41,7 @@ export function constructWebpackConfigFunction(
4241
// we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that
4342
// `incomingConfig` and `buildContext` are referred to as `config` and `options` in the nextjs docs.
4443
const newWebpackFunction = (incomingConfig: WebpackConfigObject, buildContext: BuildContext): WebpackConfigObject => {
44+
const { isServer, dev: isDev } = buildContext;
4545
let newConfig = { ...incomingConfig };
4646

4747
// if user has custom webpack config (which always takes the form of a function), run it so we have actual values to
@@ -50,6 +50,29 @@ export function constructWebpackConfigFunction(
5050
newConfig = userNextConfig.webpack(newConfig, buildContext);
5151
}
5252

53+
if (isServer) {
54+
newConfig.module = {
55+
...newConfig.module,
56+
rules: [
57+
...(newConfig.module?.rules || []),
58+
{
59+
test: /sentry\.server\.config\.(jsx?|tsx?)/,
60+
use: [
61+
{
62+
// Support non-default output directories by making the output path (easy to get here at build-time)
63+
// available to the server SDK's default `RewriteFrames` instance (which needs it at runtime), by
64+
// injecting code to attach it to `global`.
65+
loader: path.resolve(__dirname, 'prefixLoader.js'),
66+
options: {
67+
distDir: userNextConfig.distDir || '.next',
68+
},
69+
},
70+
],
71+
},
72+
],
73+
};
74+
}
75+
5376
// Tell webpack to inject user config files (containing the two `Sentry.init()` calls) into the appropriate output
5477
// bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do
5578
// 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`.
@@ -70,7 +93,7 @@ export function constructWebpackConfigFunction(
7093
// with the `--ignore-scripts` option, this will be blocked and the missing binary will cause an error when users
7194
// try to build their apps.)
7295
ensureCLIBinaryExists() &&
73-
(buildContext.isServer
96+
(isServer
7497
? !userNextConfig.sentry?.disableServerWebpackPlugin
7598
: !userNextConfig.sentry?.disableClientWebpackPlugin);
7699

@@ -80,14 +103,13 @@ export function constructWebpackConfigFunction(
80103

81104
// Next doesn't let you change `devtool` in dev even if you want to, so don't bother trying - see
82105
// https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md
83-
if (!buildContext.dev) {
106+
if (!isDev) {
84107
// `hidden-source-map` produces the same sourcemaps as `source-map`, but doesn't include the `sourceMappingURL`
85108
// comment at the bottom. For folks who aren't publicly hosting their sourcemaps, this is helpful because then
86109
// the browser won't look for them and throw errors into the console when it can't find them. Because this is a
87110
// front-end-only problem, and because `sentry-cli` handles sourcemaps more reliably with the comment than
88111
// without, the option to use `hidden-source-map` only applies to the client-side build.
89-
newConfig.devtool =
90-
userNextConfig.sentry?.hideSourceMaps && !buildContext.isServer ? 'hidden-source-map' : 'source-map';
112+
newConfig.devtool = userNextConfig.sentry?.hideSourceMaps && !isServer ? 'hidden-source-map' : 'source-map';
91113
}
92114

93115
newConfig.plugins = newConfig.plugins || [];
@@ -121,7 +143,7 @@ async function addSentryToEntryProperty(
121143
// we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function
122144
// options. See https://webpack.js.org/configuration/entry-context/#entry.
123145

124-
const { isServer, dir: projectDir, dev: isDev, config: userNextConfig } = buildContext;
146+
const { isServer, dir: projectDir } = buildContext;
125147

126148
const newEntryProperty =
127149
typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty };
@@ -132,21 +154,6 @@ async function addSentryToEntryProperty(
132154
// we need to turn the filename into a path so webpack can find it
133155
const filesToInject = [`./${userConfigFile}`];
134156

135-
// Support non-default output directories by making the output path (easy to get here at build-time) available to the
136-
// server SDK's default `RewriteFrames` instance (which needs it at runtime). Doesn't work when using the dev server
137-
// because it somehow tricks the file watcher into thinking that compilation itself is a file change, triggering an
138-
// infinite recompiling loop. (This should be fine because we don't upload sourcemaps in dev in any case.)
139-
if (isServer && !isDev) {
140-
const rewriteFramesHelper = path.resolve(
141-
fs.mkdtempSync(path.resolve(os.tmpdir(), 'sentry-')),
142-
'rewriteFramesHelper.js',
143-
);
144-
fs.writeFileSync(rewriteFramesHelper, `global.__rewriteFramesDistDir__ = '${userNextConfig.distDir}';\n`);
145-
// stick our helper file ahead of the user's config file so the value is in the global namespace *before*
146-
// `Sentry.init()` is called
147-
filesToInject.unshift(rewriteFramesHelper);
148-
}
149-
150157
// inject into all entry points which might contain user's code
151158
for (const entryPointName in newEntryProperty) {
152159
if (shouldAddSentryToEntryPoint(entryPointName, isServer)) {

packages/nextjs/src/index.server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function sdkAlreadyInitialized(): boolean {
110110

111111
function addServerIntegrations(options: NextjsOptions): void {
112112
// This value is injected at build time, based on the output directory specified in the build config
113-
const distDirName = (global as GlobalWithDistDir).__rewriteFramesDistDir__ || '.next';
113+
const distDirName = (global as GlobalWithDistDir).__rewriteFramesDistDir__;
114114
// nextjs always puts the build directory at the project root level, which is also where you run `next start` from, so
115115
// we can read in the project directory from the currently running process
116116
const distDirAbsPath = path.resolve(process.cwd(), distDirName);

packages/nextjs/test/config.test.ts

+32-49
Original file line numberDiff line numberDiff line change
@@ -332,35 +332,26 @@ describe('webpack config', () => {
332332
incomingWebpackBuildContext: serverBuildContext,
333333
});
334334

335-
const tempDir = mkdtempSyncSpy.mock.results[0].value;
336-
const rewriteFramesHelper = path.join(tempDir, 'rewriteFramesHelper.js');
337-
338335
expect(finalWebpackConfig.entry).toEqual(
339336
expect.objectContaining({
340337
// original entrypoint value is a string
341338
// (was 'private-next-pages/_error.js')
342-
'pages/_error': [rewriteFramesHelper, serverConfigFilePath, 'private-next-pages/_error.js'],
339+
'pages/_error': [serverConfigFilePath, 'private-next-pages/_error.js'],
343340

344341
// original entrypoint value is a string array
345342
// (was ['./node_modules/smellOVision/index.js', 'private-next-pages/_app.js'])
346-
'pages/_app': [
347-
rewriteFramesHelper,
348-
serverConfigFilePath,
349-
'./node_modules/smellOVision/index.js',
350-
'private-next-pages/_app.js',
351-
],
343+
'pages/_app': [serverConfigFilePath, './node_modules/smellOVision/index.js', 'private-next-pages/_app.js'],
352344

353345
// original entrypoint value is an object containing a string `import` value
354346
// (was { import: 'private-next-pages/api/simulator/dogStats/[name].js' })
355347
'pages/api/simulator/dogStats/[name]': {
356-
import: [rewriteFramesHelper, serverConfigFilePath, 'private-next-pages/api/simulator/dogStats/[name].js'],
348+
import: [serverConfigFilePath, 'private-next-pages/api/simulator/dogStats/[name].js'],
357349
},
358350

359351
// original entrypoint value is an object containing a string array `import` value
360352
// (was { import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/api/simulator/leaderboard.js'] })
361353
'pages/api/simulator/leaderboard': {
362354
import: [
363-
rewriteFramesHelper,
364355
serverConfigFilePath,
365356
'./node_modules/dogPoints/converter.js',
366357
'private-next-pages/api/simulator/leaderboard.js',
@@ -370,7 +361,7 @@ describe('webpack config', () => {
370361
// original entrypoint value is an object containg properties besides `import`
371362
// (was { import: 'private-next-pages/api/tricks/[trickName].js', dependOn: 'treats', })
372363
'pages/api/tricks/[trickName]': {
373-
import: [rewriteFramesHelper, serverConfigFilePath, 'private-next-pages/api/tricks/[trickName].js'],
364+
import: [serverConfigFilePath, 'private-next-pages/api/tricks/[trickName].js'],
374365
dependOn: 'treats', // untouched
375366
},
376367
}),
@@ -478,53 +469,45 @@ describe('webpack config', () => {
478469
}),
479470
);
480471
});
472+
});
481473

482-
it('does not inject `RewriteFrames` helper into client routes', async () => {
474+
describe('webpack loaders', () => {
475+
it('adds loader to server config', async () => {
476+
const finalWebpackConfig = await materializeFinalWebpackConfig({
477+
userNextConfig,
478+
incomingWebpackConfig: serverWebpackConfig,
479+
incomingWebpackBuildContext: serverBuildContext,
480+
});
481+
482+
expect(finalWebpackConfig.module!.rules).toEqual(
483+
expect.arrayContaining([
484+
{
485+
test: expect.any(RegExp),
486+
use: [
487+
{
488+
loader: expect.any(String),
489+
// Having no criteria for what the object contains is better than using `expect.any(Object)`, because that
490+
// could be anything
491+
options: expect.objectContaining({}),
492+
},
493+
],
494+
},
495+
]),
496+
);
497+
});
498+
499+
it("doesn't add loader to client config", async () => {
483500
const finalWebpackConfig = await materializeFinalWebpackConfig({
484501
userNextConfig,
485502
incomingWebpackConfig: clientWebpackConfig,
486503
incomingWebpackBuildContext: clientBuildContext,
487504
});
488505

489-
expect(finalWebpackConfig.entry).toEqual(
490-
expect.objectContaining({
491-
// was 'next-client-pages-loader?page=%2F_app', and now has client config but not`RewriteFrames` helper injected
492-
'pages/_app': [clientConfigFilePath, 'next-client-pages-loader?page=%2F_app'],
493-
}),
494-
);
506+
expect(finalWebpackConfig.module).toBeUndefined();
495507
});
496508
});
497509

498510
describe('`distDir` value in default server-side `RewriteFrames` integration', () => {
499-
it.each([
500-
['no custom `distDir`', undefined, '.next'],
501-
['custom `distDir`', 'dist', 'dist'],
502-
])(
503-
'creates file injecting `distDir` value into `global` - %s',
504-
async (_name, customDistDir, expectedInjectedValue) => {
505-
// Note: the fact that the file tested here gets injected correctly is covered in the 'webpack `entry` property
506-
// config' tests above
507-
508-
const userNextConfigDistDir = {
509-
...userNextConfig,
510-
...(customDistDir && { distDir: customDistDir }),
511-
};
512-
await materializeFinalWebpackConfig({
513-
userNextConfig: userNextConfigDistDir,
514-
incomingWebpackConfig: serverWebpackConfig,
515-
incomingWebpackBuildContext: getBuildContext('server', userNextConfigDistDir),
516-
});
517-
518-
const tempDir = mkdtempSyncSpy.mock.results[0].value;
519-
const rewriteFramesHelper = path.join(tempDir, 'rewriteFramesHelper.js');
520-
521-
expect(fs.existsSync(rewriteFramesHelper)).toBe(true);
522-
523-
const injectedCode = fs.readFileSync(rewriteFramesHelper).toString();
524-
expect(injectedCode).toEqual(`global.__rewriteFramesDistDir__ = '${expectedInjectedValue}';\n`);
525-
},
526-
);
527-
528511
describe('`RewriteFrames` ends up with correct `distDir` value', () => {
529512
// TODO: this, along with any number of other parts of the build process, should be tested with an integration
530513
// test which actually runs webpack and inspects the resulting bundles (and that integration test should test

0 commit comments

Comments
 (0)