Skip to content

Commit db1edce

Browse files
authored
ref(nextjs): Split up config code and add tests (#3693)
1 parent 6829c8c commit db1edce

File tree

7 files changed

+590
-246
lines changed

7 files changed

+590
-246
lines changed

packages/nextjs/src/config/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ExportedNextConfig, NextConfigObject, SentryWebpackPluginOptions } from './types';
2+
import { constructWebpackConfigFunction } from './webpack';
3+
4+
/**
5+
* Add Sentry options to the config to be exported from the user's `next.config.js` file.
6+
*
7+
* @param userNextConfig The existing config to be exported prior to adding Sentry
8+
* @param userSentryWebpackPluginOptions Configuration for SentryWebpackPlugin
9+
* @returns The modified config to be exported
10+
*/
11+
export function withSentryConfig(
12+
userNextConfig: ExportedNextConfig = {},
13+
userSentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions> = {},
14+
): NextConfigObject {
15+
const newWebpackExport = constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginOptions);
16+
17+
const finalNextConfig = {
18+
...userNextConfig,
19+
// TODO When we add a way to disable the webpack plugin, doing so should turn this off, too
20+
productionBrowserSourceMaps: true,
21+
webpack: newWebpackExport,
22+
};
23+
24+
return finalNextConfig;
25+
}

packages/nextjs/src/config/types.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
export { SentryCliPluginOptions as SentryWebpackPluginOptions } from '@sentry/webpack-plugin';
2+
3+
/**
4+
* Overall Nextjs config
5+
*/
6+
7+
export type ExportedNextConfig = NextConfigObject;
8+
9+
export type NextConfigObject = {
10+
// whether or not next should create source maps for browser code
11+
// see: https://nextjs.org/docs/advanced-features/source-maps
12+
productionBrowserSourceMaps?: boolean;
13+
// custom webpack options
14+
webpack?: WebpackConfigFunction;
15+
} & {
16+
// other `next.config.js` options
17+
[key: string]: unknown;
18+
};
19+
20+
/**
21+
* Webpack config
22+
*/
23+
24+
// the format for providing custom webpack config in your nextjs options
25+
export type WebpackConfigFunction = (config: WebpackConfigObject, options: BuildContext) => WebpackConfigObject;
26+
27+
export type WebpackConfigObject = {
28+
devtool?: string;
29+
plugins?: Array<{ [key: string]: unknown }>;
30+
entry: WebpackEntryProperty;
31+
output: { filename: string; path: string };
32+
target: string;
33+
context: string;
34+
} & {
35+
// other webpack options
36+
[key: string]: unknown;
37+
};
38+
39+
// Information about the current build environment
40+
export type BuildContext = { dev: boolean; isServer: boolean; buildId: string };
41+
42+
/**
43+
* Webpack `entry` config
44+
*/
45+
46+
// For our purposes, the value for `entry` is either an object, or an async function which returns such an object
47+
export type WebpackEntryProperty = EntryPropertyObject | EntryPropertyFunction;
48+
49+
// Each value in that object is either a string representing a single entry point, an array of such strings, or an
50+
// object containing either of those, along with other configuration options. In that third case, the entry point(s) are
51+
// listed under the key `import`.
52+
export type EntryPropertyObject =
53+
| { [key: string]: string }
54+
| { [key: string]: Array<string> }
55+
| { [key: string]: EntryPointObject }; // only in webpack 5
56+
57+
export type EntryPropertyFunction = () => Promise<EntryPropertyObject>;
58+
59+
// An object with options for a single entry point, potentially one of many in the webpack `entry` property
60+
export type EntryPointObject = { import: string | Array<string> };

packages/nextjs/src/config/utils.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
import { WebpackConfigObject } from './types';
5+
6+
export const SENTRY_CLIENT_CONFIG_FILE = './sentry.client.config.js';
7+
export const SENTRY_SERVER_CONFIG_FILE = './sentry.server.config.js';
8+
// this is where the transpiled/bundled version of `SENTRY_SERVER_CONFIG_FILE` will end up
9+
export const SERVER_SDK_INIT_PATH = 'sentry/initServerSDK.js';
10+
11+
/**
12+
* Store the path to the bundled version of the user's server config file (where `Sentry.init` is called).
13+
*
14+
* @param config Incoming webpack configuration, passed to the `webpack` function we set in the nextjs config.
15+
*/
16+
export function storeServerConfigFileLocation(config: WebpackConfigObject): void {
17+
const outputLocation = path.dirname(path.join(config.output.path, config.output.filename));
18+
const serverSDKInitOutputPath = path.join(outputLocation, SERVER_SDK_INIT_PATH);
19+
const projectDir = config.context;
20+
setRuntimeEnvVars(projectDir, {
21+
// ex: .next/server/sentry/initServerSdk.js
22+
SENTRY_SERVER_INIT_PATH: path.relative(projectDir, serverSDKInitOutputPath),
23+
});
24+
}
25+
26+
/**
27+
* Set variables to be added to the env at runtime, by storing them in `.env.local` (which `next` automatically reads
28+
* into memory at server startup).
29+
*
30+
* @param projectDir The path to the project root
31+
* @param vars Object containing vars to set
32+
*/
33+
export function setRuntimeEnvVars(projectDir: string, vars: { [key: string]: string }): void {
34+
// ensure the file exists
35+
const envFilePath = path.join(projectDir, '.env.local');
36+
if (!fs.existsSync(envFilePath)) {
37+
fs.writeFileSync(envFilePath, '');
38+
}
39+
40+
let fileContents = fs
41+
.readFileSync(envFilePath)
42+
.toString()
43+
.trim();
44+
45+
Object.entries(vars).forEach(entry => {
46+
const [varName, value] = entry;
47+
const envVarString = `${varName}=${value}`;
48+
49+
// new entry
50+
if (!fileContents.includes(varName)) {
51+
fileContents = `${fileContents}\n${envVarString}`;
52+
}
53+
// existing entry; make sure value is up to date
54+
else {
55+
fileContents = fileContents.replace(new RegExp(`${varName}=\\S+`), envVarString);
56+
}
57+
});
58+
59+
fs.writeFileSync(envFilePath, `${fileContents.trim()}\n`);
60+
}

packages/nextjs/src/config/webpack.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { getSentryRelease } from '@sentry/node';
2+
import { dropUndefinedKeys, logger } from '@sentry/utils';
3+
import * as SentryWebpackPlugin from '@sentry/webpack-plugin';
4+
5+
import {
6+
BuildContext,
7+
EntryPropertyObject,
8+
ExportedNextConfig,
9+
SentryWebpackPluginOptions,
10+
WebpackConfigFunction,
11+
WebpackConfigObject,
12+
WebpackEntryProperty,
13+
} from './types';
14+
import {
15+
SENTRY_CLIENT_CONFIG_FILE,
16+
SENTRY_SERVER_CONFIG_FILE,
17+
SERVER_SDK_INIT_PATH,
18+
storeServerConfigFileLocation,
19+
} from './utils';
20+
21+
export { SentryWebpackPlugin };
22+
23+
// TODO: merge default SentryWebpackPlugin ignore with their SentryWebpackPlugin ignore or ignoreFile
24+
// TODO: merge default SentryWebpackPlugin include with their SentryWebpackPlugin include
25+
// TODO: drop merged keys from override check? `includeDefaults` option?
26+
27+
const defaultSentryWebpackPluginOptions = dropUndefinedKeys({
28+
url: process.env.SENTRY_URL,
29+
org: process.env.SENTRY_ORG,
30+
project: process.env.SENTRY_PROJECT,
31+
authToken: process.env.SENTRY_AUTH_TOKEN,
32+
configFile: 'sentry.properties',
33+
stripPrefix: ['webpack://_N_E/'],
34+
urlPrefix: `~/_next`,
35+
include: '.next/',
36+
ignore: ['.next/cache', 'server/ssr-module-cache.js', 'static/*/_ssgManifest.js', 'static/*/_buildManifest.js'],
37+
});
38+
39+
/**
40+
* Construct the function which will be used as the nextjs config's `webpack` value.
41+
*
42+
* Sets:
43+
* - `devtool`, to ensure high-quality sourcemaps are generated
44+
* - `entry`, to include user's sentry config files (where `Sentry.init` is called) in the build
45+
* - `plugins`, to add SentryWebpackPlugin (TODO: optional)
46+
*
47+
* @param userNextConfig The user's existing nextjs config, as passed to `withSentryConfig`
48+
* @param userSentryWebpackPluginOptions The user's SentryWebpackPlugin config, as passed to `withSentryConfig`
49+
* @returns The function to set as the nextjs config's `webpack` value
50+
*/
51+
export function constructWebpackConfigFunction(
52+
userNextConfig: ExportedNextConfig = {},
53+
userSentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions> = {},
54+
): WebpackConfigFunction {
55+
const newWebpackFunction = (config: WebpackConfigObject, options: BuildContext): WebpackConfigObject => {
56+
// if we're building server code, store the webpack output path as an env variable, so we know where to look for the
57+
// webpack-processed version of `sentry.server.config.js` when we need it
58+
if (config.target === 'node') {
59+
storeServerConfigFileLocation(config);
60+
}
61+
62+
let newConfig = config;
63+
64+
// if user has custom webpack config (which always takes the form of a function), run it so we have actual values to
65+
// work with
66+
if ('webpack' in userNextConfig && typeof userNextConfig.webpack === 'function') {
67+
newConfig = userNextConfig.webpack(config, options);
68+
}
69+
70+
// Ensure quality source maps in production. (Source maps aren't uploaded in dev, and besides, Next doesn't let you
71+
// change this is dev even if you want to - see
72+
// https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md.)
73+
if (!options.dev) {
74+
// TODO Handle possibility that user is using `SourceMapDevToolPlugin` (see
75+
// https://webpack.js.org/plugins/source-map-dev-tool-plugin/)
76+
// TODO Give user option to use `hidden-source-map` ?
77+
newConfig.devtool = 'source-map';
78+
}
79+
80+
// Tell webpack to inject user config files (containing the two `Sentry.init()` calls) into the appropriate output
81+
// bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do
82+
// 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`.
83+
// Since we're setting `x.y` to be a callback (which, by definition, won't run until some time later), by the time
84+
// the function runs (causing `f` to run, causing `x.y` to run), `x.y` will point to the callback itself, rather
85+
// than its original value. So calling it will call the callback which will call `f` which will call `x.y` which
86+
// will call the callback which will call `f` which will call `x.y`... and on and on. Theoretically this could also
87+
// be fixed by using `bind`, but this is way simpler.)
88+
const origEntryProperty = newConfig.entry;
89+
newConfig.entry = async () => addSentryToEntryProperty(origEntryProperty, options.isServer);
90+
91+
// Add the Sentry plugin, which uploads source maps to Sentry when not in dev
92+
checkWebpackPluginOverrides(userSentryWebpackPluginOptions);
93+
newConfig.plugins = newConfig.plugins || [];
94+
newConfig.plugins.push(
95+
// @ts-ignore Our types for the plugin are messed up somehow - TS wants this to be `SentryWebpackPlugin.default`,
96+
// but that's not actually a thing
97+
new SentryWebpackPlugin({
98+
dryRun: options.dev,
99+
release: getSentryRelease(options.buildId),
100+
...defaultSentryWebpackPluginOptions,
101+
...userSentryWebpackPluginOptions,
102+
}),
103+
);
104+
105+
return newConfig;
106+
};
107+
108+
return newWebpackFunction;
109+
}
110+
111+
/**
112+
* Modify the webpack `entry` property so that the code in `sentry.server.config.js` and `sentry.client.config.js` is
113+
* included in the the necessary bundles.
114+
*
115+
* @param origEntryProperty The value of the property before Sentry code has been injected
116+
* @param isServer A boolean provided by nextjs indicating whether we're handling the server bundles or the browser
117+
* bundles
118+
* @returns The value which the new `entry` property (which will be a function) will return (TODO: this should return
119+
* the function, rather than the function's return value)
120+
*/
121+
async function addSentryToEntryProperty(
122+
origEntryProperty: WebpackEntryProperty,
123+
isServer: boolean,
124+
): Promise<EntryPropertyObject> {
125+
// The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs
126+
// sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether
127+
// someone else has come along before us and changed that, we need to check a few things along the way. The one thing
128+
// we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function
129+
// options. See https://webpack.js.org/configuration/entry-context/#entry.
130+
131+
let newEntryProperty = origEntryProperty;
132+
if (typeof origEntryProperty === 'function') {
133+
newEntryProperty = await origEntryProperty();
134+
}
135+
newEntryProperty = newEntryProperty as EntryPropertyObject;
136+
137+
// Add a new element to the `entry` array, we force webpack to create a bundle out of the user's
138+
// `sentry.server.config.js` file and output it to `SERVER_INIT_LOCATION`. (See
139+
// https://webpack.js.org/guides/code-splitting/#entry-points.) We do this so that the user's config file is run
140+
// through babel (and any other processors through which next runs the rest of the user-provided code - pages, API
141+
// routes, etc.). Specifically, we need any ESM-style `import` code to get transpiled into ES5, so that we can call
142+
// `require()` on the resulting file when we're instrumenting the sesrver. (We can't use a dynamic import there
143+
// because that then forces the user into a particular TS config.)
144+
145+
// On the server, create a separate bundle, as there's no one entry point depended on by all the others
146+
if (isServer) {
147+
// slice off the final `.js` since webpack is going to add it back in for us, and we don't want to end up with
148+
// `.js.js` as the extension
149+
newEntryProperty[SERVER_SDK_INIT_PATH.slice(0, -3)] = SENTRY_SERVER_CONFIG_FILE;
150+
}
151+
// On the client, it's sufficient to inject it into the `main` JS code, which is included in every browser page.
152+
else {
153+
addFileToExistingEntryPoint(newEntryProperty, 'main', SENTRY_CLIENT_CONFIG_FILE);
154+
}
155+
156+
return newEntryProperty;
157+
}
158+
159+
/**
160+
* Add a file to a specific element of the given `entry` webpack config property.
161+
*
162+
* @param entryProperty The existing `entry` config object
163+
* @param entryPointName The key where the file should be injected
164+
* @param filepath The path to the injected file
165+
*/
166+
function addFileToExistingEntryPoint(
167+
entryProperty: EntryPropertyObject,
168+
entryPointName: string,
169+
filepath: string,
170+
): void {
171+
// can be a string, array of strings, or object whose `import` property is one of those two
172+
let injectedInto = entryProperty[entryPointName];
173+
174+
// Sometimes especially for older next.js versions it happens we don't have an entry point
175+
if (!injectedInto) {
176+
// eslint-disable-next-line no-console
177+
console.error(`[Sentry] Can't inject ${filepath}, no entrypoint is defined.`);
178+
return;
179+
}
180+
181+
// We inject the user's client config file after the existing code so that the config file has access to
182+
// `publicRuntimeConfig`. See https://github.com/getsentry/sentry-javascript/issues/3485
183+
if (typeof injectedInto === 'string') {
184+
injectedInto = [injectedInto, filepath];
185+
} else if (Array.isArray(injectedInto)) {
186+
injectedInto = [...injectedInto, filepath];
187+
} else {
188+
let importVal: string | string[];
189+
190+
if (typeof injectedInto.import === 'string') {
191+
importVal = [injectedInto.import, filepath];
192+
} else {
193+
importVal = [...injectedInto.import, filepath];
194+
}
195+
196+
injectedInto = {
197+
...injectedInto,
198+
import: importVal,
199+
};
200+
}
201+
202+
entryProperty[entryPointName] = injectedInto;
203+
}
204+
205+
/**
206+
* Check the SentryWebpackPlugin options provided by the user against the options we set by default, and warn if any of
207+
* our default options are getting overridden. (Note: If any of our default values is undefined, it won't be included in
208+
* the warning.)
209+
*
210+
* @param userSentryWebpackPluginOptions The user's SentryWebpackPlugin options
211+
*/
212+
function checkWebpackPluginOverrides(userSentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions>): void {
213+
// warn if any of the default options for the webpack plugin are getting overridden
214+
const sentryWebpackPluginOptionOverrides = Object.keys(defaultSentryWebpackPluginOptions)
215+
.concat('dryrun')
216+
.filter(key => key in userSentryWebpackPluginOptions);
217+
if (sentryWebpackPluginOptionOverrides.length > 0) {
218+
logger.warn(
219+
'[Sentry] You are overriding the following automatically-set SentryWebpackPlugin config options:\n' +
220+
`\t${sentryWebpackPluginOptionOverrides.toString()},\n` +
221+
"which has the possibility of breaking source map upload and application. This is only a good idea if you know what you're doing.",
222+
);
223+
}
224+
}

packages/nextjs/src/index.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function addServerIntegrations(options: NextjsOptions): void {
5353
}
5454
}
5555

56-
export { withSentryConfig } from './utils/config';
56+
export { withSentryConfig } from './config';
5757
export { withSentry } from './utils/handlers';
5858

5959
// wrap various server methods to enable error monitoring and tracing

0 commit comments

Comments
 (0)