Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nextjs): Support distDir Next.js option #3990

Merged
merged 18 commits into from
Sep 22, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/nextjs/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { includeDistDir } from './nextConfigToWebpackPluginConfig';
import { ExportedNextConfig, NextConfigFunction, NextConfigObject, SentryWebpackPluginOptions } from './types';
import { constructWebpackConfigFunction } from './webpack';

Expand All @@ -17,13 +18,21 @@ export function withSentryConfig(
if (typeof userNextConfig === 'function') {
return function(phase: string, defaults: { defaultConfig: NextConfigObject }): NextConfigObject {
const materializedUserNextConfig = userNextConfig(phase, defaults);
const sentryWebpackPluginOptionsWithSources = includeDistDir(
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
materializedUserNextConfig,
userSentryWebpackPluginOptions,
);
return {
...materializedUserNextConfig,
webpack: constructWebpackConfigFunction(materializedUserNextConfig, userSentryWebpackPluginOptions),
webpack: constructWebpackConfigFunction(materializedUserNextConfig, sentryWebpackPluginOptionsWithSources),
};
};
}

const webpackPluginOptionsWithSources = includeDistDir(userNextConfig, userSentryWebpackPluginOptions);
// Otherwise, we can just merge their config with ours and return an object.
return { ...userNextConfig, webpack: constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginOptions) };
return {
...userNextConfig,
webpack: constructWebpackConfigFunction(userNextConfig, webpackPluginOptionsWithSources),
};
}
61 changes: 61 additions & 0 deletions packages/nextjs/src/config/nextConfigToWebpackPluginConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextConfigObject, SentryWebpackPluginOptions } from './types';

/**
* Creates a new Sentry Webpack Plugin config with the `distDir` option from Next.js config
* in the `include` property.
*
* If no `distDir` is provided, the Webpack Plugin config doesn't change.
* If no `include` has been defined defined, the `distDir` value is assigned.
* The `distDir` directory is merged to the directories in `include`, if defined.
* Duplicated paths are removed while merging.
*
* @param nextConfig User's Next.js config
* @param sentryWebpackPluginOptions User's Sentry Webpack Plugin config
* @returns New Sentry Webpack Plugin config
*/
export function includeDistDir(
nextConfig: NextConfigObject,
sentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions>,
): Partial<SentryWebpackPluginOptions> {
if (!nextConfig.distDir) {
return { ...sentryWebpackPluginOptions };
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
}
// It's assumed `distDir` is a string as that's what Next.js is expecting. If it's not, Next.js itself will complain
const usersInclude = sentryWebpackPluginOptions.include;

let sourcesToInclude;
if (typeof usersInclude === 'undefined') {
sourcesToInclude = nextConfig.distDir;
} else if (typeof usersInclude === 'string') {
sourcesToInclude = usersInclude === nextConfig.distDir ? usersInclude : [usersInclude, nextConfig.distDir];
} else if (Array.isArray(usersInclude)) {
// @ts-ignore '__spreadArray' import from tslib, ts(2343)
sourcesToInclude = [...new Set(usersInclude.concat(nextConfig.distDir))];
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
} else {
// Object
if (Array.isArray(usersInclude.paths)) {
const uniquePaths = [...new Set(usersInclude.paths.concat(nextConfig.distDir as string))];
sourcesToInclude = { ...usersInclude, paths: uniquePaths };
} else if (typeof usersInclude.paths === 'undefined') {
// eslint-disable-next-line no-console
console.warn(
'Sentry Logger [Warn]:',
`An object was set in \`include\` but no \`paths\` was provided, so added the \`distDir\`: "${nextConfig.distDir}"\n` +
'See https://github.com/getsentry/sentry-webpack-plugin#optionsinclude',
);
sourcesToInclude = { ...usersInclude, paths: [nextConfig.distDir] };
} else {
// eslint-disable-next-line no-console
console.error(
'Sentry Logger [Error]:',
'Found unexpected object in `include.paths`\n' +
'See https://github.com/getsentry/sentry-webpack-plugin#optionsinclude',
);
// Keep the same object even if it's incorrect, so that the user can get a more precise error from sentry-cli
// Casting to `any` for TS not complaining about it being `unknown`
sourcesToInclude = usersInclude as any;
}
}

return { ...sentryWebpackPluginOptions, include: sourcesToInclude };
}
11 changes: 6 additions & 5 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,24 +256,25 @@ function shouldAddSentryToEntryPoint(entryPointName: string): boolean {
* @param userPluginOptions User-provided SentryWebpackPlugin options
* @returns Final set of combined options
*/
function getWebpackPluginOptions(
export function getWebpackPluginOptions(
buildContext: BuildContext,
userPluginOptions: Partial<SentryWebpackPluginOptions>,
): SentryWebpackPluginOptions {
const { isServer, dir: projectDir, buildId, dev: isDev, config: nextConfig, webpack } = buildContext;
const distDir = nextConfig.distDir ?? '.next'; // `.next` is the default directory

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

const serverInclude = isServerless
? [{ paths: ['.next/serverless/'], urlPrefix: `${urlPrefix}/serverless` }]
: [{ paths: ['.next/server/pages/'], urlPrefix: `${urlPrefix}/server/pages` }].concat(
isWebpack5 ? [{ paths: ['.next/server/chunks/'], urlPrefix: `${urlPrefix}/server/chunks` }] : [],
? [{ paths: [`${distDir}/serverless/`], urlPrefix: `${urlPrefix}/serverless` }]
: [{ paths: [`${distDir}/server/pages/`], urlPrefix: `${urlPrefix}/server/pages` }].concat(
isWebpack5 ? [{ paths: [`${distDir}/server/chunks/`], urlPrefix: `${urlPrefix}/server/chunks` }] : [],
);

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

const defaultPluginOptions = dropUndefinedKeys({
include: isServer ? serverInclude : clientInclude,
Expand Down
40 changes: 37 additions & 3 deletions packages/nextjs/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
SentryWebpackPluginOptions,
WebpackConfigObject,
} from '../src/config/types';
import { constructWebpackConfigFunction, getUserConfigFile, SentryWebpackPlugin } from '../src/config/webpack';
import {
constructWebpackConfigFunction,
getUserConfigFile,
getWebpackPluginOptions,
SentryWebpackPlugin,
} from '../src/config/webpack';

const SERVER_SDK_CONFIG_FILE = 'sentry.server.config.js';
const CLIENT_SDK_CONFIG_FILE = 'sentry.client.config.js';
Expand Down Expand Up @@ -89,16 +94,21 @@ const clientWebpackConfig = {
// In real life, next will copy the `userNextConfig` into the `buildContext`. Since we're providing mocks for both of
// those, we need to mimic that behavior, and since `userNextConfig` can vary per test, we need to have the option do it
// dynamically.
function getBuildContext(buildTarget: 'server' | 'client', userNextConfig: Partial<NextConfigObject>): BuildContext {
function getBuildContext(
buildTarget: 'server' | 'client',
userNextConfig: Partial<NextConfigObject>,
webpackVersion: string = '5.4.15',
): BuildContext {
return {
dev: false,
buildId: 'sItStAyLiEdOwN',
dir: '/Users/Maisey/projects/squirrelChasingSimulator',
config: { target: 'server', ...userNextConfig },
webpack: { version: '5.4.15' },
webpack: { version: webpackVersion },
isServer: buildTarget === 'server',
};
}

const serverBuildContext = getBuildContext('server', userNextConfig);
const clientBuildContext = getBuildContext('client', userNextConfig);

Expand Down Expand Up @@ -580,4 +590,28 @@ describe('Sentry webpack plugin config', () => {
);
});
});

it.each([
/** `distDir` is not defined */
[getBuildContext('client', {}), '.next'],
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
[getBuildContext('server', { target: 'experimental-serverless-trace' }), '.next'], // serverless
[getBuildContext('server', {}, '4'), '.next'],
[getBuildContext('server', {}, '5'), '.next'],

/** `distDir` is defined */
[getBuildContext('client', { distDir: 'tmpDir' }), 'tmpDir'],
[getBuildContext('server', { distDir: 'tmpDir', target: 'experimental-serverless-trace' }), 'tmpDir'], // serverless
[getBuildContext('server', { distDir: 'tmpDir' }, '4'), 'tmpDir'],
[getBuildContext('server', { distDir: 'tmpDir' }, '5'), 'tmpDir'],
])('correct paths from `distDir` in WebpackPluginOptions', (buildContext: BuildContext, expectedDistDir) => {
const includePaths = getWebpackPluginOptions(buildContext, {
/** userPluginOptions */
}).include as { paths: [] }[];

for (const pathDescriptor of includePaths) {
for (const path of pathDescriptor.paths) {
expect(path).toMatch(new RegExp(`^${expectedDistDir}.*`));
}
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { includeDistDir } from '../../src/config/nextConfigToWebpackPluginConfig';

describe('next config to webpack plugin config', () => {
describe('includeDistDir', () => {
const consoleWarnMock = jest.fn();
const consoleErrorMock = jest.fn();

beforeAll(() => {
global.console.warn = consoleWarnMock;
global.console.error = consoleErrorMock;
});

afterAll(() => {
jest.restoreAllMocks();
});

test.each([
[{}, {}, {}],
[{}, { include: 'path' }, { include: 'path' }],
[{}, { include: [] }, { include: [] }],
[{}, { include: ['path'] }, { include: ['path'] }],
[{}, { include: { paths: ['path'] } }, { include: { paths: ['path'] } }],
])('without `distDir`', (nextConfig, webpackPluginConfig, expectedConfig) => {
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
});

test.each([
[{ distDir: 'test' }, {}, { include: 'test' }],
[{ distDir: 'test' }, { include: 'path' }, { include: ['path', 'test'] }],
[{ distDir: 'test' }, { include: [] }, { include: ['test'] }],
[{ distDir: 'test' }, { include: ['path'] }, { include: ['path', 'test'] }],
[{ distDir: 'test' }, { include: { paths: ['path'] } }, { include: { paths: ['path', 'test'] } }],
])('with `distDir`, different paths', (nextConfig, webpackPluginConfig, expectedConfig) => {
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
});

test.each([
[{ distDir: 'path' }, { include: 'path' }, { include: 'path' }],
[{ distDir: 'path' }, { include: ['path'] }, { include: ['path'] }],
[{ distDir: 'path' }, { include: { paths: ['path'] } }, { include: { paths: ['path'] } }],
])('with `distDir`, same path', (nextConfig, webpackPluginConfig, expectedConfig) => {
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
});

test.each([
[{ distDir: 'path' }, { include: {} }, { include: { paths: ['path'] } }],
[{ distDir: 'path' }, { include: { prop: 'val' } }, { include: { prop: 'val', paths: ['path'] } }],
])('webpack plugin config as object with other prop', (nextConfig, webpackPluginConfig, expectedConfig) => {
// @ts-ignore Other props don't match types
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
expect(consoleWarnMock).toHaveBeenCalledTimes(1);
consoleWarnMock.mockClear();
});

test.each([
[{ distDir: 'path' }, { include: { paths: {} } }, { include: { paths: {} } }],
[{ distDir: 'path' }, { include: { paths: { badObject: true } } }, { include: { paths: { badObject: true } } }],
])('webpack plugin config as object with bad structure', (nextConfig, webpackPluginConfig, expectedConfig) => {
// @ts-ignore Bad structures don't match types
expect(includeDistDir(nextConfig, webpackPluginConfig)).toMatchObject(expectedConfig);
expect(consoleErrorMock).toHaveBeenCalledTimes(1);
consoleErrorMock.mockClear();
});
});
});