From 918c12ed1b6aa886e6e2cfa4d9e4f416d5207339 Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Wed, 16 Jun 2021 21:42:10 -0700 Subject: [PATCH 1/2] split up config code --- packages/nextjs/src/config/index.ts | 25 +++ packages/nextjs/src/config/types.ts | 60 +++++++ packages/nextjs/src/config/utils.ts | 60 +++++++ packages/nextjs/src/config/webpack.ts | 224 +++++++++++++++++++++++ packages/nextjs/src/index.server.ts | 2 +- packages/nextjs/src/utils/config.ts | 245 -------------------------- 6 files changed, 370 insertions(+), 246 deletions(-) create mode 100644 packages/nextjs/src/config/index.ts create mode 100644 packages/nextjs/src/config/types.ts create mode 100644 packages/nextjs/src/config/utils.ts create mode 100644 packages/nextjs/src/config/webpack.ts delete mode 100644 packages/nextjs/src/utils/config.ts diff --git a/packages/nextjs/src/config/index.ts b/packages/nextjs/src/config/index.ts new file mode 100644 index 000000000000..f4725b88a93b --- /dev/null +++ b/packages/nextjs/src/config/index.ts @@ -0,0 +1,25 @@ +import { ExportedNextConfig, NextConfigObject, SentryWebpackPluginOptions } from './types'; +import { constructWebpackConfigFunction } from './webpack'; + +/** + * Add Sentry options to the config to be exported from the user's `next.config.js` file. + * + * @param userNextConfig The existing config to be exported prior to adding Sentry + * @param userSentryWebpackPluginOptions Configuration for SentryWebpackPlugin + * @returns The modified config to be exported + */ +export function withSentryConfig( + userNextConfig: ExportedNextConfig = {}, + userSentryWebpackPluginOptions: Partial = {}, +): NextConfigObject { + const newWebpackExport = constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginOptions); + + const finalNextConfig = { + ...userNextConfig, + // TODO When we add a way to disable the webpack plugin, doing so should turn this off, too + productionBrowserSourceMaps: true, + webpack: newWebpackExport, + }; + + return finalNextConfig; +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts new file mode 100644 index 000000000000..e68b5312d38f --- /dev/null +++ b/packages/nextjs/src/config/types.ts @@ -0,0 +1,60 @@ +export { SentryCliPluginOptions as SentryWebpackPluginOptions } from '@sentry/webpack-plugin'; + +/** + * Overall Nextjs config + */ + +export type ExportedNextConfig = NextConfigObject; + +export type NextConfigObject = { + // whether or not next should create source maps for browser code + // see: https://nextjs.org/docs/advanced-features/source-maps + productionBrowserSourceMaps?: boolean; + // custom webpack options + webpack?: WebpackConfigFunction; +} & { + // other `next.config.js` options + [key: string]: unknown; +}; + +/** + * Webpack config + */ + +// the format for providing custom webpack config in your nextjs options +export type WebpackConfigFunction = (config: WebpackConfigObject, options: BuildContext) => WebpackConfigObject; + +export type WebpackConfigObject = { + devtool?: string; + plugins?: Array<{ [key: string]: unknown }>; + entry: WebpackEntryProperty; + output: { filename: string; path: string }; + target: string; + context: string; +} & { + // other webpack options + [key: string]: unknown; +}; + +// Information about the current build environment +export type BuildContext = { dev: boolean; isServer: boolean; buildId: string }; + +/** + * Webpack `entry` config + */ + +// For our purposes, the value for `entry` is either an object, or an async function which returns such an object +export type WebpackEntryProperty = EntryPropertyObject | EntryPropertyFunction; + +// Each value in that object is either a string representing a single entry point, an array of such strings, or an +// object containing either of those, along with other configuration options. In that third case, the entry point(s) are +// listed under the key `import`. +export type EntryPropertyObject = + | { [key: string]: string } + | { [key: string]: Array } + | { [key: string]: EntryPointObject }; // only in webpack 5 + +export type EntryPropertyFunction = () => Promise; + +// An object with options for a single entry point, potentially one of many in the webpack `entry` property +export type EntryPointObject = { import: string | Array }; diff --git a/packages/nextjs/src/config/utils.ts b/packages/nextjs/src/config/utils.ts new file mode 100644 index 000000000000..b9944894402c --- /dev/null +++ b/packages/nextjs/src/config/utils.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { WebpackConfigObject } from './types'; + +export const SENTRY_CLIENT_CONFIG_FILE = './sentry.client.config.js'; +export const SENTRY_SERVER_CONFIG_FILE = './sentry.server.config.js'; +// this is where the transpiled/bundled version of `SENTRY_SERVER_CONFIG_FILE` will end up +export const SERVER_SDK_INIT_PATH = 'sentry/initServerSDK.js'; + +/** + * Store the path to the bundled version of the user's server config file (where `Sentry.init` is called). + * + * @param config Incoming webpack configuration, passed to the `webpack` function we set in the nextjs config. + */ +export function storeServerConfigFileLocation(config: WebpackConfigObject): void { + const outputLocation = path.dirname(path.join(config.output.path, config.output.filename)); + const serverSDKInitOutputPath = path.join(outputLocation, SERVER_SDK_INIT_PATH); + const projectDir = config.context; + setRuntimeEnvVars(projectDir, { + // ex: .next/server/sentry/initServerSdk.js + SENTRY_SERVER_INIT_PATH: path.relative(projectDir, serverSDKInitOutputPath), + }); +} + +/** + * Set variables to be added to the env at runtime, by storing them in `.env.local` (which `next` automatically reads + * into memory at server startup). + * + * @param projectDir The path to the project root + * @param vars Object containing vars to set + */ +export function setRuntimeEnvVars(projectDir: string, vars: { [key: string]: string }): void { + // ensure the file exists + const envFilePath = path.join(projectDir, '.env.local'); + if (!fs.existsSync(envFilePath)) { + fs.writeFileSync(envFilePath, ''); + } + + let fileContents = fs + .readFileSync(envFilePath) + .toString() + .trim(); + + Object.entries(vars).forEach(entry => { + const [varName, value] = entry; + const envVarString = `${varName}=${value}`; + + // new entry + if (!fileContents.includes(varName)) { + fileContents = `${fileContents}\n${envVarString}`; + } + // existing entry; make sure value is up to date + else { + fileContents = fileContents.replace(new RegExp(`${varName}=\\S+`), envVarString); + } + }); + + fs.writeFileSync(envFilePath, `${fileContents.trim()}\n`); +} diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts new file mode 100644 index 000000000000..5d5edaa4e62a --- /dev/null +++ b/packages/nextjs/src/config/webpack.ts @@ -0,0 +1,224 @@ +import { getSentryRelease } from '@sentry/node'; +import { dropUndefinedKeys, logger } from '@sentry/utils'; +import * as SentryWebpackPlugin from '@sentry/webpack-plugin'; + +import { + BuildContext, + EntryPropertyObject, + ExportedNextConfig, + SentryWebpackPluginOptions, + WebpackConfigFunction, + WebpackConfigObject, + WebpackEntryProperty, +} from './types'; +import { + SENTRY_CLIENT_CONFIG_FILE, + SENTRY_SERVER_CONFIG_FILE, + SERVER_SDK_INIT_PATH, + storeServerConfigFileLocation, +} from './utils'; + +export { SentryWebpackPlugin }; + +// TODO: merge default SentryWebpackPlugin ignore with their SentryWebpackPlugin ignore or ignoreFile +// TODO: merge default SentryWebpackPlugin include with their SentryWebpackPlugin include +// TODO: drop merged keys from override check? `includeDefaults` option? + +const defaultSentryWebpackPluginOptions = dropUndefinedKeys({ + url: process.env.SENTRY_URL, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + configFile: 'sentry.properties', + stripPrefix: ['webpack://_N_E/'], + urlPrefix: `~/_next`, + include: '.next/', + ignore: ['.next/cache', 'server/ssr-module-cache.js', 'static/*/_ssgManifest.js', 'static/*/_buildManifest.js'], +}); + +/** + * Construct the function which will be used as the nextjs config's `webpack` value. + * + * Sets: + * - `devtool`, to ensure high-quality sourcemaps are generated + * - `entry`, to include user's sentry config files (where `Sentry.init` is called) in the build + * - `plugins`, to add SentryWebpackPlugin (TODO: optional) + * + * @param userNextConfig The user's existing nextjs config, as passed to `withSentryConfig` + * @param userSentryWebpackPluginOptions The user's SentryWebpackPlugin config, as passed to `withSentryConfig` + * @returns The function to set as the nextjs config's `webpack` value + */ +export function constructWebpackConfigFunction( + userNextConfig: ExportedNextConfig = {}, + userSentryWebpackPluginOptions: Partial = {}, +): WebpackConfigFunction { + const newWebpackFunction = (config: WebpackConfigObject, options: BuildContext): WebpackConfigObject => { + // if we're building server code, store the webpack output path as an env variable, so we know where to look for the + // webpack-processed version of `sentry.server.config.js` when we need it + if (config.target === 'node') { + storeServerConfigFileLocation(config); + } + + let newConfig = config; + + // if user has custom webpack config (which always takes the form of a function), run it so we have actual values to + // work with + if ('webpack' in userNextConfig && typeof userNextConfig.webpack === 'function') { + newConfig = userNextConfig.webpack(config, options); + } + + // Ensure quality source maps in production. (Source maps aren't uploaded in dev, and besides, Next doesn't let you + // change this is dev even if you want to - see + // https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md.) + if (!options.dev) { + // TODO Handle possibility that user is using `SourceMapDevToolPlugin` (see + // https://webpack.js.org/plugins/source-map-dev-tool-plugin/) + // TODO Give user option to use `hidden-source-map` ? + newConfig.devtool = 'source-map'; + } + + // 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`. + // Since we're setting `x.y` to be a callback (which, by definition, won't run until some time later), by the time + // the function runs (causing `f` to run, causing `x.y` to run), `x.y` will point to the callback itself, rather + // than its original value. So calling it will call the callback which will call `f` which will call `x.y` which + // will call the callback which will call `f` which will call `x.y`... and on and on. Theoretically this could also + // be fixed by using `bind`, but this is way simpler.) + const origEntryProperty = newConfig.entry; + newConfig.entry = async () => addSentryToEntryProperty(origEntryProperty, options.isServer); + + // Add the Sentry plugin, which uploads source maps to Sentry when not in dev + checkWebpackPluginOverrides(userSentryWebpackPluginOptions); + newConfig.plugins = newConfig.plugins || []; + newConfig.plugins.push( + // @ts-ignore Our types for the plugin are messed up somehow - TS wants this to be `SentryWebpackPlugin.default`, + // but that's not actually a thing + new SentryWebpackPlugin({ + dryRun: options.dev, + release: getSentryRelease(options.buildId), + ...defaultSentryWebpackPluginOptions, + ...userSentryWebpackPluginOptions, + }), + ); + + return newConfig; + }; + + return newWebpackFunction; +} + +/** + * 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. + * + * @param origEntryProperty The value of the property before Sentry code has been injected + * @param isServer A boolean provided by nextjs indicating whether we're handling the server bundles or the browser + * bundles + * @returns The value which the new `entry` property (which will be a function) will return (TODO: this should return + * the function, rather than the function's return value) + */ +async function addSentryToEntryProperty( + origEntryProperty: WebpackEntryProperty, + isServer: boolean, +): Promise { + // The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs + // sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether + // someone else has come along before us and changed that, we need to check a few things along the way. The one thing + // we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function + // options. See https://webpack.js.org/configuration/entry-context/#entry. + + let newEntryProperty = origEntryProperty; + if (typeof origEntryProperty === 'function') { + newEntryProperty = await origEntryProperty(); + } + newEntryProperty = newEntryProperty as EntryPropertyObject; + + // Add a new element to the `entry` array, we force webpack to create a bundle out of the user's + // `sentry.server.config.js` file and output it to `SERVER_INIT_LOCATION`. (See + // https://webpack.js.org/guides/code-splitting/#entry-points.) We do this so that the user's config file is run + // through babel (and any other processors through which next runs the rest of the user-provided code - pages, API + // routes, etc.). Specifically, we need any ESM-style `import` code to get transpiled into ES5, so that we can call + // `require()` on the resulting file when we're instrumenting the sesrver. (We can't use a dynamic import there + // because that then forces the user into a particular TS config.) + + // On the server, create a separate bundle, as there's no one entry point depended on by all the others + if (isServer) { + // 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 + // `.js.js` as the extension + newEntryProperty[SERVER_SDK_INIT_PATH.slice(0, -3)] = SENTRY_SERVER_CONFIG_FILE; + } + // On the client, it's sufficient to inject it into the `main` JS code, which is included in every browser page. + else { + addFileToExistingEntryPoint(newEntryProperty, 'main', SENTRY_CLIENT_CONFIG_FILE); + } + + return newEntryProperty; +} + +/** + * Add a file to a specific element of the given `entry` webpack config property. + * + * @param entryProperty The existing `entry` config object + * @param entryPointName The key where the file should be injected + * @param filepath The path to the injected file + */ +function addFileToExistingEntryPoint( + entryProperty: EntryPropertyObject, + entryPointName: string, + filepath: string, +): void { + // can be a string, array of strings, or object whose `import` property is one of those two + let injectedInto = entryProperty[entryPointName]; + + // Sometimes especially for older next.js versions it happens we don't have an entry point + if (!injectedInto) { + // eslint-disable-next-line no-console + console.error(`[Sentry] Can't inject ${filepath}, no entrypoint is defined.`); + return; + } + + // We inject the user's client config file after the existing code so that the config file has access to + // `publicRuntimeConfig`. See https://github.com/getsentry/sentry-javascript/issues/3485 + if (typeof injectedInto === 'string') { + injectedInto = [injectedInto, filepath]; + } else if (Array.isArray(injectedInto)) { + injectedInto = [...injectedInto, filepath]; + } else { + let importVal: string | string[]; + + if (typeof injectedInto.import === 'string') { + importVal = [injectedInto.import, filepath]; + } else { + importVal = [...injectedInto.import, filepath]; + } + + injectedInto = { + ...injectedInto, + import: importVal, + }; + } + + entryProperty[entryPointName] = injectedInto; +} + +/** + * Check the SentryWebpackPlugin options provided by the user against the options we set by default, and warn if any of + * our default options are getting overridden. (Note: If any of our default values is undefined, it won't be included in + * the warning.) + * + * @param userSentryWebpackPluginOptions The user's SentryWebpackPlugin options + */ +function checkWebpackPluginOverrides(userSentryWebpackPluginOptions: Partial): void { + // warn if any of the default options for the webpack plugin are getting overridden + const sentryWebpackPluginOptionOverrides = Object.keys(defaultSentryWebpackPluginOptions) + .concat('dryrun') + .filter(key => key in userSentryWebpackPluginOptions); + if (sentryWebpackPluginOptionOverrides.length > 0) { + logger.warn( + '[Sentry] You are overriding the following automatically-set SentryWebpackPlugin config options:\n' + + `\t${sentryWebpackPluginOptionOverrides.toString()},\n` + + "which has the possibility of breaking source map upload and application. This is only a good idea if you know what you're doing.", + ); + } +} diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index 2370fa21efc6..84eec5c6469f 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -53,7 +53,7 @@ function addServerIntegrations(options: NextjsOptions): void { } } -export { withSentryConfig } from './utils/config'; +export { withSentryConfig } from './config'; export { withSentry } from './utils/handlers'; // wrap various server methods to enable error monitoring and tracing diff --git a/packages/nextjs/src/utils/config.ts b/packages/nextjs/src/utils/config.ts deleted file mode 100644 index 1bb56529f604..000000000000 --- a/packages/nextjs/src/utils/config.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { getSentryRelease } from '@sentry/node'; -import { dropUndefinedKeys, logger } from '@sentry/utils'; -import defaultWebpackPlugin, { SentryCliPluginOptions } from '@sentry/webpack-plugin'; -import * as SentryWebpackPlugin from '@sentry/webpack-plugin'; -import * as fs from 'fs'; -import { NextConfig } from 'next/dist/next-server/server/config'; -import * as path from 'path'; - -const SENTRY_CLIENT_CONFIG_FILE = './sentry.client.config.js'; -const SENTRY_SERVER_CONFIG_FILE = './sentry.server.config.js'; -// this is where the transpiled/bundled version of `SENTRY_SERVER_CONFIG_FILE` will end up -export const SERVER_SDK_INIT_PATH = 'sentry/initServerSDK.js'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PlainObject = { [key: string]: T }; - -// The function which is ultimately going to be exported from `next.config.js` under the name `webpack` -type WebpackExport = (config: WebpackConfig, options: WebpackOptions) => WebpackConfig; - -// The two arguments passed to the exported `webpack` function, as well as the thing it returns -type WebpackConfig = { - devtool: string; - plugins: PlainObject[]; - entry: EntryProperty; - output: { filename: string; path: string }; - target: string; - context: string; -}; -type WebpackOptions = { dev: boolean; isServer: boolean; buildId: string }; - -// For our purposes, the value for `entry` is either an object, or a function which returns such an object -type EntryProperty = (() => Promise) | EntryPropertyObject; -// Each value in that object is either a string representing a single entry point, an array of such strings, or an -// object containing either of those, along with other configuration options. In that third case, the entry point(s) are -// listed under the key `import`. -type EntryPropertyObject = PlainObject | PlainObject> | PlainObject; -type EntryPointObject = { import: string | Array }; - -/** - * Add a file to a specific element of the given `entry` webpack config property. - * - * @param entryProperty The existing `entry` config object - * @param injectionPoint The key where the file should be injected - * @param injectee The path to the injected file - */ -const _injectFile = (entryProperty: EntryPropertyObject, injectionPoint: string, injectee: string): void => { - // can be a string, array of strings, or object whose `import` property is one of those two - let injectedInto = entryProperty[injectionPoint]; - - // Sometimes especially for older next.js versions it happens we don't have an entry point - if (!injectedInto) { - // eslint-disable-next-line no-console - console.error(`[Sentry] Can't inject ${injectee}, no entrypoint is defined.`); - return; - } - - // We inject the user's client config file after the existing code so that the config file has access to - // `publicRuntimeConfig`. See https://github.com/getsentry/sentry-javascript/issues/3485 - if (typeof injectedInto === 'string') { - injectedInto = [injectedInto, injectee]; - } else if (Array.isArray(injectedInto)) { - injectedInto = [...injectedInto, injectee]; - } else { - let importVal: string | string[]; - - if (typeof injectedInto.import === 'string') { - importVal = [injectedInto.import, injectee]; - } else { - importVal = [...injectedInto.import, injectee]; - } - - injectedInto = { - ...injectedInto, - import: importVal, - }; - } - - entryProperty[injectionPoint] = injectedInto; -}; - -const injectSentry = async (origEntryProperty: EntryProperty, isServer: boolean): Promise => { - // The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs - // sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether - // someone else has come along before us and changed that, we need to check a few things along the way. The one thing - // we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function - // options. See https://webpack.js.org/configuration/entry-context/#entry. - - let newEntryProperty = origEntryProperty; - if (typeof origEntryProperty === 'function') { - newEntryProperty = await origEntryProperty(); - } - newEntryProperty = newEntryProperty as EntryPropertyObject; - - // Add a new element to the `entry` array, we force webpack to create a bundle out of the user's - // `sentry.server.config.js` file and output it to `SERVER_INIT_LOCATION`. (See - // https://webpack.js.org/guides/code-splitting/#entry-points.) We do this so that the user's config file is run - // through babel (and any other processors through which next runs the rest of the user-provided code - pages, API - // routes, etc.). Specifically, we need any ESM-style `import` code to get transpiled into ES5, so that we can call - // `require()` on the resulting file when we're instrumenting the sesrver. (We can't use a dynamic import there - // because that then forces the user into a particular TS config.) - if (isServer) { - // 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 - // `.js.js` as the extension - newEntryProperty[SERVER_SDK_INIT_PATH.slice(0, -3)] = SENTRY_SERVER_CONFIG_FILE; - } - // On the client, it's sufficient to inject it into the `main` JS code, which is included in every browser page. - else { - _injectFile(newEntryProperty, 'main', SENTRY_CLIENT_CONFIG_FILE); - } - - return newEntryProperty; -}; - -type NextConfigExports = Partial & { - productionBrowserSourceMaps?: boolean; - webpack?: WebpackExport; -}; - -/** - * Add Sentry options to the config to be exported from the user's `next.config.js` file. - * - * @param providedExports The existing config to be exported ,prior to adding Sentry - * @param providedSentryWebpackPluginOptions Configuration for SentryWebpackPlugin - * @returns The modified config to be exported - */ -export function withSentryConfig( - providedExports: Partial = {}, - providedSentryWebpackPluginOptions: Partial = {}, -): NextConfigExports { - const defaultSentryWebpackPluginOptions = dropUndefinedKeys({ - url: process.env.SENTRY_URL, - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: process.env.SENTRY_AUTH_TOKEN, - configFile: 'sentry.properties', - stripPrefix: ['webpack://_N_E/'], - urlPrefix: `~/_next`, - include: '.next/', - ignore: ['.next/cache', 'server/ssr-module-cache.js', 'static/*/_ssgManifest.js', 'static/*/_buildManifest.js'], - }); - - // warn if any of the default options for the webpack plugin are getting overridden - const sentryWebpackPluginOptionOverrides = Object.keys(defaultSentryWebpackPluginOptions) - .concat('dryrun') - .filter(key => key in providedSentryWebpackPluginOptions); - if (sentryWebpackPluginOptionOverrides.length > 0) { - logger.warn( - '[Sentry] You are overriding the following automatically-set SentryWebpackPlugin config options:\n' + - `\t${sentryWebpackPluginOptionOverrides.toString()},\n` + - "which has the possibility of breaking source map upload and application. This is only a good idea if you know what you're doing.", - ); - } - - const newWebpackExport = (config: WebpackConfig, options: WebpackOptions): WebpackConfig => { - // if we're building server code, store the webpack output path as an env variable, so we know where to look for the - // webpack-processed version of `sentry.server.config.js` when we need it - if (config.target === 'node') { - const outputLocation = path.dirname(path.join(config.output.path, config.output.filename)); - const serverSDKInitOutputPath = path.join(outputLocation, SERVER_SDK_INIT_PATH); - const projectDir = config.context; - setRuntimeEnvVars(projectDir, { - // ex: .next/server/sentry/initServerSdk.js - SENTRY_SERVER_INIT_PATH: path.relative(projectDir, serverSDKInitOutputPath), - }); - } - - let newConfig = config; - - if (typeof providedExports.webpack === 'function') { - newConfig = providedExports.webpack(config, options); - } - - // Ensure quality source maps in production. (Source maps aren't uploaded in dev, and besides, Next doesn't let you - // change this is dev even if you want to - see - // https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md.) - if (!options.dev) { - newConfig.devtool = 'source-map'; - } - - // 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. (In a synchronous - // world, `x = () => f(x)` is fine, because the dereferencing is guaranteed to happen before the assignment, meaning - // we know f will get the original value of x. But in an async world, if we do `x = async () => f(x)`, the - // assignment happens *before* the dereferencing, meaning f is passed the new value. In other words, in that - // scenario, the new value is defined in terms of itself, with predictably bad consequences. Theoretically this - // could also be fixed by using `bind`, but this is way simpler.) - const origEntryProperty = newConfig.entry; - newConfig.entry = () => injectSentry(origEntryProperty, options.isServer); - - // Add the Sentry plugin, which uploads source maps to Sentry when not in dev - newConfig.plugins.push( - // TODO it's not clear how to do this better, but there *must* be a better way - new ((SentryWebpackPlugin as unknown) as typeof defaultWebpackPlugin)({ - dryRun: options.dev, - release: getSentryRelease(options.buildId), - ...defaultSentryWebpackPluginOptions, - ...providedSentryWebpackPluginOptions, - }), - ); - - return newConfig; - }; - - return { - ...providedExports, - productionBrowserSourceMaps: true, - webpack: newWebpackExport, - }; -} - -/** - * Set variables to be added to the env at runtime, by storing them in `.env.local` (which `next` automatically reads - * into memory at server startup). - * - * @param projectDir The path to the project root - * @param vars Object containing vars to set - */ -function setRuntimeEnvVars(projectDir: string, vars: PlainObject): void { - // ensure the file exists - const envFilePath = path.join(projectDir, '.env.local'); - if (!fs.existsSync(envFilePath)) { - fs.writeFileSync(envFilePath, ''); - } - - let fileContents = fs - .readFileSync(envFilePath) - .toString() - .trim(); - - Object.entries(vars).forEach(entry => { - const [varName, value] = entry; - const envVarString = `${varName}=${value}`; - - // new entry - if (!fileContents.includes(varName)) { - fileContents = `${fileContents}\n${envVarString}`; - } - // existing entry; make sure value is up to date - else { - fileContents = fileContents.replace(new RegExp(`${varName}=\\S+`), envVarString); - } - }); - - fs.writeFileSync(envFilePath, `${fileContents.trim()}\n`); -} From 748ea46bb099a9b0537933a53ace9a75906e915a Mon Sep 17 00:00:00 2001 From: Katie Byers Date: Wed, 16 Jun 2021 22:48:00 -0700 Subject: [PATCH 2/2] add tests --- packages/nextjs/test/config.test.ts | 220 ++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 packages/nextjs/test/config.test.ts diff --git a/packages/nextjs/test/config.test.ts b/packages/nextjs/test/config.test.ts new file mode 100644 index 000000000000..813186d5293a --- /dev/null +++ b/packages/nextjs/test/config.test.ts @@ -0,0 +1,220 @@ +import { withSentryConfig } from '../src/config'; +import { + BuildContext, + EntryPropertyFunction, + ExportedNextConfig, + NextConfigObject, + SentryWebpackPluginOptions, + WebpackConfigObject, +} from '../src/config/types'; +import { SENTRY_SERVER_CONFIG_FILE, SERVER_SDK_INIT_PATH, storeServerConfigFileLocation } from '../src/config/utils'; +import { constructWebpackConfigFunction, SentryWebpackPlugin } from '../src/config/webpack'; + +// mock `storeServerConfigFileLocation` in order to make it a no-op when necessary +jest.mock('../src/config/utils', () => { + const original = jest.requireActual('../src/config/utils'); + return { + ...original, + storeServerConfigFileLocation: jest.fn().mockImplementation(original.setRuntimeEnvVars), + }; +}); + +/** mocks of the arguments passed to `withSentryConfig` */ +const userNextConfig = { + publicRuntimeConfig: { location: 'dogpark', activities: ['fetch', 'chasing', 'digging'] }, + webpack: (config: WebpackConfigObject, _options: BuildContext) => ({ + ...config, + mode: 'universal-sniffing', + entry: async () => + Promise.resolve({ + ...(await (config.entry as EntryPropertyFunction)()), + simulatorBundle: './src/simulator/index.ts', + }), + }), +}; +const userSentryWebpackPluginConfig = { org: 'squirrelChasers', project: 'simulator', include: './thirdPartyMaps' }; + +/** mocks of the arguments passed to `nextConfig.webpack` */ +const serverWebpackConfig = { + entry: () => Promise.resolve({ 'pages/api/dogs/[name]': 'private-next-pages/api/dogs/[name].js' }), + output: { filename: '[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' }, + target: 'node', + context: '/Users/Maisey/projects/squirrelChasingSimulator', +}; +const clientWebpackConfig = { + entry: () => Promise.resolve({ main: './src/index.ts' }), + output: { filename: 'static/chunks/[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' }, + target: 'web', + context: '/Users/Maisey/projects/squirrelChasingSimulator', +}; +const buildContext = { isServer: true, dev: false, buildId: 'doGsaREgReaT' }; + +/** + * Derive the final values of all next config options, by first applying `withSentryConfig` and then running the + * resulting function. + * + * @param userNextConfig Next config options provided by the user + * @param userSentryWebpackPluginConfig SentryWebpackPlugin options provided by the user + * + * @returns The config values next will receive when it calls the function returned by `withSentryConfig` + */ +function materializeFinalNextConfig( + userNextConfig: ExportedNextConfig, + userSentryWebpackPluginConfig: SentryWebpackPluginOptions, +): NextConfigObject { + const finalConfigValues = withSentryConfig(userNextConfig, userSentryWebpackPluginConfig); + + return finalConfigValues; +} + +/** + * Derive the final values of all webpack config options, by first applying `constructWebpackConfigFunction` and then + * running the resulting function. Since the `entry` property of the resulting object is itself a function, also call + * that. + * + * @param options An object including the following: + * - `userNextConfig` Next config options provided by the user + * - `userSentryWebpackPluginConfig` SentryWebpackPlugin options provided by the user + * - `incomingWebpackConfig` The existing webpack config, passed to the function as `config` + * - `incomingWebpackBuildContext` The existing webpack build context, passed to the function as `options` + * + * @returns The webpack config values next will use when it calls the function that `createFinalWebpackConfig` returns + */ +async function materializeFinalWebpackConfig(options: { + userNextConfig: ExportedNextConfig; + userSentryWebpackPluginConfig: SentryWebpackPluginOptions; + incomingWebpackConfig: WebpackConfigObject; + incomingWebpackBuildContext: BuildContext; +}): Promise { + const { userNextConfig, userSentryWebpackPluginConfig, incomingWebpackConfig, incomingWebpackBuildContext } = options; + + // get the webpack config function we'd normally pass back to next + const webpackConfigFunction = constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginConfig); + + // call it to get concrete values for comparison + const finalWebpackConfigValue = webpackConfigFunction(incomingWebpackConfig, incomingWebpackBuildContext); + const webpackEntryProperty = finalWebpackConfigValue.entry as EntryPropertyFunction; + finalWebpackConfigValue.entry = await webpackEntryProperty(); + + return finalWebpackConfigValue; +} + +describe('withSentryConfig', () => { + it('includes expected properties', () => { + const finalConfig = materializeFinalNextConfig(userNextConfig, userSentryWebpackPluginConfig); + + expect(finalConfig).toEqual( + expect.objectContaining({ + productionBrowserSourceMaps: true, + webpack: expect.any(Function), // `webpack` is tested specifically elsewhere + }), + ); + }); + + it('preserves unrelated next config options', () => { + const finalConfig = materializeFinalNextConfig(userNextConfig, userSentryWebpackPluginConfig); + + expect(finalConfig.publicRuntimeConfig).toEqual(userNextConfig.publicRuntimeConfig); + }); + + it("works when user's overall config is an object", () => { + const finalConfig = materializeFinalNextConfig(userNextConfig, userSentryWebpackPluginConfig); + + expect(finalConfig).toEqual( + expect.objectContaining({ + ...userNextConfig, + productionBrowserSourceMaps: true, + webpack: expect.any(Function), // `webpack` is tested specifically elsewhere + }), + ); + }); +}); + +describe('webpack config', () => { + beforeEach(() => { + // nuke this so it won't try to look for our dummy paths + (storeServerConfigFileLocation as jest.Mock).mockImplementationOnce(() => undefined); + }); + + it('includes expected properties', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + userNextConfig, + userSentryWebpackPluginConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: buildContext, + }); + + expect(finalWebpackConfig).toEqual( + expect.objectContaining({ + devtool: 'source-map', + entry: expect.any(Object), // `entry` is tested specifically elsewhere + plugins: expect.arrayContaining([expect.any(SentryWebpackPlugin)]), + }), + ); + }); + + it('preserves unrelated webpack config options', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + userNextConfig, + userSentryWebpackPluginConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: buildContext, + }); + + // Run the user's webpack config function, so we can check the results against ours. Delete `entry` because we'll + // test it separately, and besides, it's one that we *should* be overwriting. + const materializedUserWebpackConfig = userNextConfig.webpack(serverWebpackConfig, buildContext); + // @ts-ignore `entry` may be required in real life, but we don't need it for our tests + delete materializedUserWebpackConfig.entry; + + expect(finalWebpackConfig).toEqual(expect.objectContaining(materializedUserWebpackConfig)); + }); + + describe('webpack `entry` property config', () => { + it('injects correct code when building server bundle', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + userNextConfig, + userSentryWebpackPluginConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: buildContext, + }); + + expect(finalWebpackConfig.entry).toEqual( + expect.objectContaining({ + [SERVER_SDK_INIT_PATH.slice(0, -3)]: SENTRY_SERVER_CONFIG_FILE, + }), + ); + }); + + it('injects correct code when building client bundle', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + userNextConfig, + userSentryWebpackPluginConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: { ...buildContext, isServer: false }, + }); + + expect(finalWebpackConfig.entry).toEqual( + expect.objectContaining({ main: expect.arrayContaining(['./sentry.client.config.js']) }), + ); + }); + }); +}); + +describe('Sentry webpack plugin config', () => { + it('includes expected properties', () => { + // pass + }); + + it('preserves unrelated plugin config options', () => { + // pass + }); + + it('warns when overriding certain default values', () => { + // pass + }); + + it("merges default include and ignore/ignoreFile options with user's values", () => { + // do we even want to do this? + }); +});