Skip to content

Commit 3d9f0fd

Browse files
authored
fix(nextjs): Exclude build-time files from page dependency manifests (#6058)
When nextjs 12+ builds a user's app, it creates a `.nft.json` file for each page[1], listing all of the files upon which the page depends. This is used, for example, by Vercel, when it's bundling each page into its own lambda function. Under the hood, nextjs uses the `@vercel/nft` package[2] to do the dependency tracing. Unfortunately, it seems to run before treeshaking, so any file imported into a given module will be included, even if the thing which is imported from that file gets treeshaken away. In our case, that means that even though `withSentryConfig` will never be called in anyone's app (and therefore none of its dependents - not only our loaders but also sucrase and rollup, among other things - should be included in any page's dependency list), the fact that `Sentry.init` _is_ included in a user's app, and that `withSentryConfig` is exported from `index.server.js` right alongside `init`, means that in fact files from`src/config` _are_ included when they shouldn't be. Fortunately, nextjs runs `@vercel/nft` through a webpack plugin, and that plugin takes an option to exclude files. We therefore can add `src/config/withSentryConfig.ts` to that option's array when we're modifying the app's webpack config and it will prevent that file (and any of its descendants) from being included. Notes: - Files in `src/config` come in three flavors: files modifying the user's webpack config, templates read in by our loaders, and files referenced by the code we inject (the wrappers). For historical reasons, `src/config/index.ts` only contains the first kind of file. Since it's not actually indexing all three kinds of files, I instead renamed it `withSentryConfig.ts`, after the only thing it exports. This not only makes it clearer what it's about, it also doesn't give the false impression that it's the single export point for everything in `src/config`. - To test this, I took a first stab at an integration test system focused on testing the code we use to modify the user's build. It's not especially fancy, and I'm very open to opinions on how it could be better, but it seems for the moment to at least do the trick we currently need. - One issue this PR _doesn't_ address is the fact that we still have files from the browser SDK included in server-side page dependency manifests, because `index.server.ts` exports our react error boundary, and `@sentry/react` imports from `@sentry/browser`. It would take some work to make `@sentry/react` depend a little less on `@sentry/browser` and a little more on `@sentry/utils` and `@sentry/core`, such that we'd be able to exclude all files from `@sentry/browser`, so that will have to happen in a separate PR. [1] https://nextjs.org/docs/advanced-features/output-file-tracing [2] https://github.com/vercel/nft
1 parent 4ffeedd commit 3d9f0fd

23 files changed

+1571
-9
lines changed

packages/nextjs/.eslintrc.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,19 @@ module.exports = {
1818
project: ['../../tsconfig.dev.json'],
1919
},
2020
},
21+
{
22+
files: ['test/buildProcess/**'],
23+
parserOptions: {
24+
sourceType: 'module',
25+
},
26+
plugins: ['react'],
27+
extends: ['../../.eslintrc.js', 'plugin:react/recommended'],
28+
rules: {
29+
// Prop types validation is not useful in test environments
30+
'react/prop-types': 'off',
31+
// Nextjs takes care of including react, so we don't explicitly need to
32+
'react/react-in-jsx-scope': 'off',
33+
},
34+
},
2135
],
2236
};

packages/nextjs/jest.config.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
module.exports = require('../../jest/jest.config.js');
1+
const baseConfig = require('../../jest/jest.config.js');
2+
3+
module.exports = {
4+
...baseConfig,
5+
// This prevents the build tests from running when unit tests run. (If they do, they fail, because the build being
6+
// tested hasn't necessarily run yet.)
7+
testPathIgnorePatterns: ['<rootDir>/test/buildProcess/'],
8+
};

packages/nextjs/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"devDependencies": {
3535
"@sentry/nextjs": "7.20.1",
3636
"@types/webpack": "^4.41.31",
37+
"eslint-plugin-react": "^7.31.11",
3738
"next": "10.1.3"
3839
},
3940
"peerDependencies": {
@@ -65,7 +66,8 @@
6566
"lint:eslint": "eslint . --cache --cache-location '../../eslintcache/' --format stylish",
6667
"lint:prettier": "prettier --check \"{src,test,scripts}/**/*.ts\"",
6768
"test": "run-s test:unit",
68-
"test:all": "run-s test:unit test:integration",
69+
"test:all": "run-s test:unit test:integration test:build",
70+
"test:build": "yarn ts-node test/buildProcess/runTest.ts",
6971
"test:unit": "jest",
7072
"test:integration": "test/run-integration-tests.sh && yarn test:types",
7173
"test:types": "cd test/types && yarn test",

packages/nextjs/src/config/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { WebpackPluginInstance } from 'webpack';
33

44
export type SentryWebpackPluginOptions = SentryCliPluginOptions;
55
export type SentryWebpackPlugin = WebpackPluginInstance & { options: SentryWebpackPluginOptions };
6+
// Export this from here because importing something from Webpack (the library) in `webpack.ts` confuses the heck out of
7+
// madge, which we use for circular dependency checking. We've manually excluded this file from the check (which is
8+
// safe, since it only includes types), so we can import it here without causing madge to fail. See
9+
// https://github.com/pahen/madge/issues/306.
10+
export type { WebpackPluginInstance };
611

712
/**
813
* Overall Nextjs config

packages/nextjs/src/config/webpack.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import * as chalk from 'chalk';
66
import * as fs from 'fs';
77
import * as path from 'path';
88

9-
import {
9+
// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our
10+
// circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306.
11+
import type {
1012
BuildContext,
1113
EntryPropertyObject,
1214
NextConfigObject,
@@ -16,6 +18,7 @@ import {
1618
WebpackConfigObject,
1719
WebpackEntryProperty,
1820
WebpackModuleRule,
21+
WebpackPluginInstance,
1922
} from './types';
2023

2124
export { SentryWebpackPlugin };
@@ -100,6 +103,30 @@ export function constructWebpackConfigFunction(
100103
],
101104
});
102105
}
106+
107+
// Prevent `@vercel/nft` (which nextjs uses to determine which files are needed when packaging up a lambda) from
108+
// including any of our build-time code or dependencies. (Otherwise it'll include files like this one and even the
109+
// entirety of rollup and sucrase.)
110+
const nftPlugin = newConfig.plugins?.find((plugin: WebpackPluginInstance) => {
111+
const proto = Object.getPrototypeOf(plugin) as WebpackPluginInstance;
112+
return proto.constructor.name === 'TraceEntryPointsPlugin';
113+
}) as WebpackPluginInstance & { excludeFiles: string[] };
114+
if (nftPlugin) {
115+
if (Array.isArray(nftPlugin.excludeFiles)) {
116+
nftPlugin.excludeFiles.push(path.join(__dirname, 'withSentryConfig.js'));
117+
} else {
118+
__DEBUG_BUILD__ &&
119+
logger.warn(
120+
'Unable to exclude Sentry build-time helpers from nft files. `TraceEntryPointsPlugin.excludeFiles` is not ' +
121+
'an array. This is a bug; please report this to Sentry: https://github.com/getsentry/sentry-javascript/issues/.',
122+
);
123+
}
124+
} else {
125+
__DEBUG_BUILD__ &&
126+
logger.warn(
127+
'Unable to exclude Sentry build-time helpers from nft files. Could not find `TraceEntryPointsPlugin`.',
128+
);
129+
}
103130
}
104131

105132
// The SDK uses syntax (ES6 and ES6+ features like object spread) which isn't supported by older browsers. For users

packages/nextjs/src/index.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const deprecatedIsBuild = (): boolean => isBuild();
145145
export { deprecatedIsBuild as isBuild };
146146

147147
export type { SentryWebpackPluginOptions } from './config/types';
148-
export { withSentryConfig } from './config';
148+
export { withSentryConfig } from './config/withSentryConfig';
149149
export {
150150
withSentryGetServerSideProps,
151151
withSentryGetStaticProps,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// In order to not have the build tests run as part of the unit test suite, we exclude them in
2+
// `packages/nextjs/jest.config.js`. The resets the test-matching regex so that when `runTest.ts` calls `yarn jest` with
3+
// ths config file, jest will find the tests in `tests`.
4+
module.exports = require('../../../../jest/jest.config.js');
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable no-console */
2+
import * as childProcess from 'child_process';
3+
import * as path from 'path';
4+
import { sync as rimrafSync } from 'rimraf';
5+
6+
const TEST_APP_DIR = 'test/buildProcess/testApp';
7+
8+
/**
9+
* Run the given shell command, piping the shell process's `stdin`, `stdout`, and `stderr` to that of the current
10+
* process if this script is run with the `--debug` flag. Returns contents of `stdout`.
11+
*/
12+
function run(cmd: string, options?: childProcess.ExecSyncOptions): string {
13+
return String(
14+
childProcess.execSync(cmd, {
15+
stdio: process.argv.includes('--debug') ? 'inherit' : 'ignore',
16+
...options,
17+
}),
18+
);
19+
}
20+
21+
// Note: We use a file dependency for the SDK, rather than linking it, because if it's linked, nextjs rolls the entire
22+
// SDK and all of its dependencies into a bundle, making it impossible to tell (from the NFT file, at least) what's
23+
// being included.
24+
console.log('Installing dependencies...');
25+
process.chdir(TEST_APP_DIR);
26+
rimrafSync('node_modules');
27+
run('yarn');
28+
29+
console.log('Building app...');
30+
rimrafSync('.next');
31+
run('yarn build');
32+
33+
console.log('App built. Running tests...');
34+
process.chdir('..');
35+
const jestConfigFile = path.resolve(process.cwd(), 'jest.config.js');
36+
try {
37+
// We have to specify the config file explicitly because otherwise it'll use the one at the root level of
38+
// `packages/nextjs`, since that's where the closest `package.json` file is
39+
run(`yarn jest --config ${jestConfigFile} tests`, { stdio: 'inherit' });
40+
} catch (err) {
41+
console.log('\nNot all build process tests passed.');
42+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# next.js
12+
/.next/
13+
/out/
14+
15+
# production
16+
/build
17+
18+
# misc
19+
.DS_Store
20+
*.pem
21+
22+
# debug
23+
npm-debug.log*
24+
yarn-debug.log*
25+
yarn-error.log*
26+
.pnpm-debug.log*
27+
28+
# local env files
29+
.env*.local
30+
31+
# vercel
32+
.vercel
33+
34+
# typescript
35+
*.tsbuildinfo
36+
next-env.d.ts
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// const nextConfig = {
2+
// }
3+
//
4+
// module.exports = nextConfig
5+
6+
const { withSentryConfig } = require('@sentry/nextjs');
7+
8+
const moduleExports = {
9+
reactStrictMode: true,
10+
swcMinify: true,
11+
eslint: {
12+
ignoreDuringBuilds: true,
13+
},
14+
sentry: {
15+
// Suppress the warning message from `handleSourcemapHidingOptionWarning` in `src/config/webpack.ts`
16+
// TODO (v8): This can come out in v8, because this option will get a default value
17+
hideSourceMaps: false,
18+
},
19+
};
20+
const SentryWebpackPluginOptions = {
21+
dryRun: true,
22+
silent: true,
23+
};
24+
25+
module.exports = withSentryConfig(moduleExports, SentryWebpackPluginOptions);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "testapp",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "next lint"
10+
},
11+
"dependencies": {
12+
"@sentry/nextjs": "file:../../../",
13+
"@vercel/nft": "latest",
14+
"next": "13.0.1",
15+
"react": "18.2.0",
16+
"react-dom": "18.2.0"
17+
}
18+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
Sentry.init({
4+
dsn: 'https://[email protected]/1337',
5+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
Sentry.init({
4+
dsn: 'https://[email protected]/1337',
5+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"dogs": "are great"
3+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
function MyApp({ Component, pageProps }) {
2+
return <Component {...pageProps} />;
3+
}
4+
5+
export default MyApp;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
export default function handler(req, res) {
5+
const dogData = JSON.parse(fs.readFileSync(path.resolve('../../dogs.json')));
6+
dogData.test = 'something';
7+
res.status(200).json(dogData);
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Home() {
2+
return <div>This is the homepage.</div>;
3+
}

0 commit comments

Comments
 (0)