Skip to content

Commit 27f8609

Browse files
feat(nextjs): Support distDir Next.js option (#3990)
Add support for [setting custom build directories](https://nextjs.org/docs/api-reference/next.config.js/setting-a-custom-build-directory). Although this allows to custom that directory, source maps won't work; supporting them will come in a follow-up PR.
1 parent 873f167 commit 27f8609

File tree

5 files changed

+308
-10
lines changed

5 files changed

+308
-10
lines changed

packages/nextjs/src/config/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import includeAllNextjsProps from './nextConfigToWebpackPluginConfig';
12
import { ExportedNextConfig, NextConfigFunction, NextConfigObject, SentryWebpackPluginOptions } from './types';
23
import { constructWebpackConfigFunction } from './webpack';
34

@@ -17,13 +18,21 @@ export function withSentryConfig(
1718
if (typeof userNextConfig === 'function') {
1819
return function(phase: string, defaults: { defaultConfig: NextConfigObject }): NextConfigObject {
1920
const materializedUserNextConfig = userNextConfig(phase, defaults);
21+
const sentryWebpackPluginOptionsWithSources = includeAllNextjsProps(
22+
materializedUserNextConfig,
23+
userSentryWebpackPluginOptions,
24+
);
2025
return {
2126
...materializedUserNextConfig,
22-
webpack: constructWebpackConfigFunction(materializedUserNextConfig, userSentryWebpackPluginOptions),
27+
webpack: constructWebpackConfigFunction(materializedUserNextConfig, sentryWebpackPluginOptionsWithSources),
2328
};
2429
};
2530
}
2631

32+
const webpackPluginOptionsWithSources = includeAllNextjsProps(userNextConfig, userSentryWebpackPluginOptions);
2733
// Otherwise, we can just merge their config with ours and return an object.
28-
return { ...userNextConfig, webpack: constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginOptions) };
34+
return {
35+
...userNextConfig,
36+
webpack: constructWebpackConfigFunction(userNextConfig, webpackPluginOptionsWithSources),
37+
};
2938
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { NextConfigObject, SentryWebpackPluginOptions } from './types';
2+
3+
/**
4+
* About types:
5+
* It's not possible to set strong types because they end up forcing you to explicitly
6+
* set `undefined` for properties you don't want to include, which is quite
7+
* inconvenient. The workaround to this is to relax type requirements at some point,
8+
* which means not enforcing types (why have strong typing then?) and still having code
9+
* that is hard to read.
10+
*/
11+
12+
/**
13+
* Next.js properties that should modify the webpack plugin properties.
14+
* They should have an includer function in the map.
15+
*/
16+
export const SUPPORTED_NEXTJS_PROPERTIES = ['distDir'];
17+
18+
export type PropIncluderFn = (
19+
nextConfig: NextConfigObject,
20+
sentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions>,
21+
) => Partial<SentryWebpackPluginOptions>;
22+
23+
export const PROPS_INCLUDER_MAP: Record<string, PropIncluderFn> = {
24+
distDir: includeDistDir,
25+
};
26+
27+
/**
28+
* Creates a new Sentry Webpack Plugin config from the given one, including all available
29+
* properties in the Nextjs Config.
30+
*
31+
* @param nextConfig User's Next.js config.
32+
* @param sentryWebpackPluginOptions User's Sentry Webpack Plugin config.
33+
* @returns New Sentry Webpack Plugin Config.
34+
*/
35+
export default function includeAllNextjsProps(
36+
nextConfig: NextConfigObject,
37+
sentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions>,
38+
): Partial<SentryWebpackPluginOptions> {
39+
return includeNextjsProps(nextConfig, sentryWebpackPluginOptions, PROPS_INCLUDER_MAP, SUPPORTED_NEXTJS_PROPERTIES);
40+
}
41+
42+
/**
43+
* Creates a new Sentry Webpack Plugin config from the given one, and applying the corresponding
44+
* modifications to the given next properties. If more than one option generates the same
45+
* properties, the values generated last will override previous ones.
46+
*
47+
* @param nextConfig User's Next.js config.
48+
* @param sentryWebpackPluginOptions User's Sentry Webapck Plugin config.
49+
* @param nextProps Next.js config's properties that should modify webpack plugin properties.
50+
* @returns New Sentry Webpack Plugin config.
51+
*/
52+
export function includeNextjsProps(
53+
nextConfig: NextConfigObject,
54+
sentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions>,
55+
propsIncluderMap: Record<string, PropIncluderFn>,
56+
nextProps: string[],
57+
): Partial<SentryWebpackPluginOptions> {
58+
const propsToInclude = Array.from(new Set(nextProps));
59+
return (
60+
propsToInclude
61+
// Types are not strict enought to ensure there's a function in the map
62+
.filter(prop => propsIncluderMap[prop])
63+
.map(prop => propsIncluderMap[prop](nextConfig, sentryWebpackPluginOptions))
64+
.reduce((prev, current) => ({ ...prev, ...current }), {})
65+
);
66+
}
67+
68+
/**
69+
* Creates a new Sentry Webpack Plugin config with the `distDir` option from Next.js config
70+
* in the `include` property, if `distDir` is provided.
71+
*
72+
* If no `distDir` is provided, the Webpack Plugin config doesn't change and the same object is returned.
73+
* If no `include` has been defined defined, the `distDir` value is assigned.
74+
* The `distDir` directory is merged to the directories in `include`, if defined.
75+
* Duplicated paths are removed while merging.
76+
*
77+
* @param nextConfig User's Next.js config
78+
* @param sentryWebpackPluginOptions User's Sentry Webpack Plugin config
79+
* @returns Sentry Webpack Plugin config
80+
*/
81+
export function includeDistDir(
82+
nextConfig: NextConfigObject,
83+
sentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions>,
84+
): Partial<SentryWebpackPluginOptions> {
85+
if (!nextConfig.distDir) {
86+
return sentryWebpackPluginOptions;
87+
}
88+
// It's assumed `distDir` is a string as that's what Next.js is expecting. If it's not, Next.js itself will complain
89+
const usersInclude = sentryWebpackPluginOptions.include;
90+
91+
let sourcesToInclude;
92+
if (typeof usersInclude === 'undefined') {
93+
sourcesToInclude = nextConfig.distDir;
94+
} else if (typeof usersInclude === 'string') {
95+
sourcesToInclude = usersInclude === nextConfig.distDir ? usersInclude : [usersInclude, nextConfig.distDir];
96+
} else if (Array.isArray(usersInclude)) {
97+
sourcesToInclude = Array.from(new Set(usersInclude.concat(nextConfig.distDir as string)));
98+
} else {
99+
// Object
100+
if (Array.isArray(usersInclude.paths)) {
101+
const uniquePaths = Array.from(new Set(usersInclude.paths.concat(nextConfig.distDir as string)));
102+
sourcesToInclude = { ...usersInclude, paths: uniquePaths };
103+
} else if (typeof usersInclude.paths === 'undefined') {
104+
// eslint-disable-next-line no-console
105+
console.warn(
106+
'Sentry Logger [Warn]:',
107+
`An object was set in \`include\` but no \`paths\` was provided, so added the \`distDir\`: "${nextConfig.distDir}"\n` +
108+
'See https://github.com/getsentry/sentry-webpack-plugin#optionsinclude',
109+
);
110+
sourcesToInclude = { ...usersInclude, paths: [nextConfig.distDir] };
111+
} else {
112+
// eslint-disable-next-line no-console
113+
console.error(
114+
'Sentry Logger [Error]:',
115+
'Found unexpected object in `include.paths`\n' +
116+
'See https://github.com/getsentry/sentry-webpack-plugin#optionsinclude',
117+
);
118+
// Keep the same object even if it's incorrect, so that the user can get a more precise error from sentry-cli
119+
// Casting to `any` for TS not complaining about it being `unknown`
120+
sourcesToInclude = usersInclude as any;
121+
}
122+
}
123+
124+
return { ...sentryWebpackPluginOptions, include: sourcesToInclude };
125+
}

packages/nextjs/src/config/webpack.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -256,24 +256,25 @@ function shouldAddSentryToEntryPoint(entryPointName: string): boolean {
256256
* @param userPluginOptions User-provided SentryWebpackPlugin options
257257
* @returns Final set of combined options
258258
*/
259-
function getWebpackPluginOptions(
259+
export function getWebpackPluginOptions(
260260
buildContext: BuildContext,
261261
userPluginOptions: Partial<SentryWebpackPluginOptions>,
262262
): SentryWebpackPluginOptions {
263263
const { isServer, dir: projectDir, buildId, dev: isDev, config: nextConfig, webpack } = buildContext;
264+
const distDir = nextConfig.distDir ?? '.next'; // `.next` is the default directory
264265

265266
const isWebpack5 = webpack.version.startsWith('5');
266267
const isServerless = nextConfig.target === 'experimental-serverless-trace';
267268
const hasSentryProperties = fs.existsSync(path.resolve(projectDir, 'sentry.properties'));
268269
const urlPrefix = nextConfig.basePath ? `~${nextConfig.basePath}/_next` : '~/_next';
269270

270271
const serverInclude = isServerless
271-
? [{ paths: ['.next/serverless/'], urlPrefix: `${urlPrefix}/serverless` }]
272-
: [{ paths: ['.next/server/pages/'], urlPrefix: `${urlPrefix}/server/pages` }].concat(
273-
isWebpack5 ? [{ paths: ['.next/server/chunks/'], urlPrefix: `${urlPrefix}/server/chunks` }] : [],
272+
? [{ paths: [`${distDir}/serverless/`], urlPrefix: `${urlPrefix}/serverless` }]
273+
: [{ paths: [`${distDir}/server/pages/`], urlPrefix: `${urlPrefix}/server/pages` }].concat(
274+
isWebpack5 ? [{ paths: [`${distDir}/server/chunks/`], urlPrefix: `${urlPrefix}/server/chunks` }] : [],
274275
);
275276

276-
const clientInclude = [{ paths: ['.next/static/chunks/pages'], urlPrefix: `${urlPrefix}/static/chunks/pages` }];
277+
const clientInclude = [{ paths: [`${distDir}/static/chunks/pages`], urlPrefix: `${urlPrefix}/static/chunks/pages` }];
277278

278279
const defaultPluginOptions = dropUndefinedKeys({
279280
include: isServer ? serverInclude : clientInclude,

packages/nextjs/test/config.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
SentryWebpackPluginOptions,
1414
WebpackConfigObject,
1515
} from '../src/config/types';
16-
import { constructWebpackConfigFunction, getUserConfigFile, SentryWebpackPlugin } from '../src/config/webpack';
16+
import {
17+
constructWebpackConfigFunction,
18+
getUserConfigFile,
19+
getWebpackPluginOptions,
20+
SentryWebpackPlugin,
21+
} from '../src/config/webpack';
1722

1823
const SERVER_SDK_CONFIG_FILE = 'sentry.server.config.js';
1924
const CLIENT_SDK_CONFIG_FILE = 'sentry.client.config.js';
@@ -89,16 +94,21 @@ const clientWebpackConfig = {
8994
// In real life, next will copy the `userNextConfig` into the `buildContext`. Since we're providing mocks for both of
9095
// those, we need to mimic that behavior, and since `userNextConfig` can vary per test, we need to have the option do it
9196
// dynamically.
92-
function getBuildContext(buildTarget: 'server' | 'client', userNextConfig: Partial<NextConfigObject>): BuildContext {
97+
function getBuildContext(
98+
buildTarget: 'server' | 'client',
99+
userNextConfig: Partial<NextConfigObject>,
100+
webpackVersion: string = '5.4.15',
101+
): BuildContext {
93102
return {
94103
dev: false,
95104
buildId: 'sItStAyLiEdOwN',
96105
dir: '/Users/Maisey/projects/squirrelChasingSimulator',
97106
config: { target: 'server', ...userNextConfig },
98-
webpack: { version: '5.4.15' },
107+
webpack: { version: webpackVersion },
99108
isServer: buildTarget === 'server',
100109
};
101110
}
111+
102112
const serverBuildContext = getBuildContext('server', userNextConfig);
103113
const clientBuildContext = getBuildContext('client', userNextConfig);
104114

@@ -580,4 +590,40 @@ describe('Sentry webpack plugin config', () => {
580590
);
581591
});
582592
});
593+
594+
describe('correct paths from `distDir` in WebpackPluginOptions', () => {
595+
it.each([
596+
[getBuildContext('client', {}), '.next'],
597+
[getBuildContext('server', { target: 'experimental-serverless-trace' }), '.next'], // serverless
598+
[getBuildContext('server', {}, '4'), '.next'],
599+
[getBuildContext('server', {}, '5'), '.next'],
600+
])('`distDir` is not defined', (buildContext: BuildContext, expectedDistDir) => {
601+
const includePaths = getWebpackPluginOptions(buildContext, {
602+
/** userPluginOptions */
603+
}).include as { paths: [] }[];
604+
605+
for (const pathDescriptor of includePaths) {
606+
for (const path of pathDescriptor.paths) {
607+
expect(path).toMatch(new RegExp(`^${expectedDistDir}.*`));
608+
}
609+
}
610+
});
611+
612+
it.each([
613+
[getBuildContext('client', { distDir: 'tmpDir' }), 'tmpDir'],
614+
[getBuildContext('server', { distDir: 'tmpDir', target: 'experimental-serverless-trace' }), 'tmpDir'], // serverless
615+
[getBuildContext('server', { distDir: 'tmpDir' }, '4'), 'tmpDir'],
616+
[getBuildContext('server', { distDir: 'tmpDir' }, '5'), 'tmpDir'],
617+
])('`distDir` is defined', (buildContext: BuildContext, expectedDistDir) => {
618+
const includePaths = getWebpackPluginOptions(buildContext, {
619+
/** userPluginOptions */
620+
}).include as { paths: [] }[];
621+
622+
for (const pathDescriptor of includePaths) {
623+
for (const path of pathDescriptor.paths) {
624+
expect(path).toMatch(new RegExp(`^${expectedDistDir}.*`));
625+
}
626+
}
627+
});
628+
});
583629
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import includeAllNextjsProps, {
2+
includeDistDir,
3+
includeNextjsProps,
4+
PropIncluderFn,
5+
} from '../../src/config/nextConfigToWebpackPluginConfig';
6+
import { SentryWebpackPluginOptions } from '../../src/config/types';
7+
8+
test('includeAllNextjsProps', () => {
9+
expect(includeAllNextjsProps({ distDir: 'test' }, {})).toMatchObject({ include: 'test' });
10+
});
11+
12+
describe('includeNextjsProps', () => {
13+
const includerMap: Record<string, PropIncluderFn> = {
14+
test: includeEverythingFn,
15+
};
16+
const includeEverything = {
17+
include: 'everything',
18+
};
19+
function includeEverythingFn(): Partial<SentryWebpackPluginOptions> {
20+
return includeEverything;
21+
}
22+
23+
test('a prop and an includer', () => {
24+
expect(includeNextjsProps({ test: true }, {}, includerMap, ['test'])).toMatchObject(includeEverything);
25+
});
26+
27+
test('a prop without includer', () => {
28+
expect(includeNextjsProps({ noExist: false }, {}, includerMap, ['noExist'])).toMatchObject({});
29+
});
30+
31+
test('an includer without a prop', () => {
32+
expect(includeNextjsProps({ noExist: false }, {}, includerMap, ['test'])).toMatchObject({});
33+
});
34+
35+
test('neither prop nor includer', () => {
36+
expect(includeNextjsProps({}, {}, {}, [])).toMatchObject({});
37+
});
38+
39+
test('duplicated props', () => {
40+
let counter: number = 0;
41+
const mock = jest.fn().mockImplementation(() => {
42+
const current = counter;
43+
counter += 1;
44+
return { call: current };
45+
});
46+
const map: Record<string, PropIncluderFn> = {
47+
dup: mock,
48+
};
49+
50+
expect(includeNextjsProps({}, {}, map, ['dup', 'dup'])).toMatchObject({ call: 0 });
51+
expect(mock).toHaveBeenCalledTimes(1);
52+
});
53+
});
54+
55+
describe('next config to webpack plugin config', () => {
56+
describe('includeDistDir', () => {
57+
const consoleWarnMock = jest.fn();
58+
const consoleErrorMock = jest.fn();
59+
60+
beforeAll(() => {
61+
global.console.warn = consoleWarnMock;
62+
global.console.error = consoleErrorMock;
63+
});
64+
65+
afterAll(() => {
66+
jest.restoreAllMocks();
67+
});
68+
69+
test.each([
70+
[{}, {}, {}],
71+
[{}, { include: 'path' }, { include: 'path' }],
72+
[{}, { include: [] }, { include: [] }],
73+
[{}, { include: ['path'] }, { include: ['path'] }],
74+
[{}, { include: { paths: ['path'] } }, { include: { paths: ['path'] } }],
75+
])('without `distDir`', (nextConfig, webpackPluginConfig, expectedConfig) => {
76+
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
77+
});
78+
79+
test.each([
80+
[{ distDir: 'test' }, {}, { include: 'test' }],
81+
[{ distDir: 'test' }, { include: 'path' }, { include: ['path', 'test'] }],
82+
[{ distDir: 'test' }, { include: [] }, { include: ['test'] }],
83+
[{ distDir: 'test' }, { include: ['path'] }, { include: ['path', 'test'] }],
84+
[{ distDir: 'test' }, { include: { paths: ['path'] } }, { include: { paths: ['path', 'test'] } }],
85+
])('with `distDir`, different paths', (nextConfig, webpackPluginConfig, expectedConfig) => {
86+
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
87+
});
88+
89+
test.each([
90+
[{ distDir: 'path' }, { include: 'path' }, { include: 'path' }],
91+
[{ distDir: 'path' }, { include: ['path'] }, { include: ['path'] }],
92+
[{ distDir: 'path' }, { include: { paths: ['path'] } }, { include: { paths: ['path'] } }],
93+
])('with `distDir`, same path', (nextConfig, webpackPluginConfig, expectedConfig) => {
94+
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
95+
});
96+
97+
test.each([
98+
[{ distDir: 'path' }, { include: {} }, { include: { paths: ['path'] } }],
99+
[{ distDir: 'path' }, { include: { prop: 'val' } }, { include: { prop: 'val', paths: ['path'] } }],
100+
])('webpack plugin config as object with other prop', (nextConfig, webpackPluginConfig, expectedConfig) => {
101+
// @ts-ignore Other props don't match types
102+
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
103+
expect(consoleWarnMock).toHaveBeenCalledTimes(1);
104+
consoleWarnMock.mockClear();
105+
});
106+
107+
test.each([
108+
[{ distDir: 'path' }, { include: { paths: {} } }, { include: { paths: {} } }],
109+
[{ distDir: 'path' }, { include: { paths: { badObject: true } } }, { include: { paths: { badObject: true } } }],
110+
])('webpack plugin config as object with bad structure', (nextConfig, webpackPluginConfig, expectedConfig) => {
111+
// @ts-ignore Bad structures don't match types
112+
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
113+
expect(consoleErrorMock).toHaveBeenCalledTimes(1);
114+
consoleErrorMock.mockClear();
115+
});
116+
});
117+
});

0 commit comments

Comments
 (0)