Skip to content

Commit 9a37660

Browse files
authored
feat(nextjs): Use compiler hook for uploading turbopack sourcemaps (#17352)
1 parent 3c048c3 commit 9a37660

11 files changed

+1892
-43
lines changed

packages/nextjs/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,14 @@
7979
"@opentelemetry/api": "^1.9.0",
8080
"@opentelemetry/semantic-conventions": "^1.34.0",
8181
"@rollup/plugin-commonjs": "28.0.1",
82+
"@sentry/bundler-plugin-core": "^4.3.0",
83+
"@sentry/webpack-plugin": "^4.3.0",
8284
"@sentry-internal/browser-utils": "10.9.0",
8385
"@sentry/core": "10.9.0",
8486
"@sentry/node": "10.9.0",
8587
"@sentry/opentelemetry": "10.9.0",
8688
"@sentry/react": "10.9.0",
8789
"@sentry/vercel-edge": "10.9.0",
88-
"@sentry/webpack-plugin": "^4.1.1",
8990
"chalk": "3.0.0",
9091
"resolve": "1.22.8",
9192
"rollup": "^4.35.0",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugin-core';
2+
import * as path from 'path';
3+
import type { SentryBuildOptions } from './types';
4+
5+
/**
6+
* Get Sentry Build Plugin options for the runAfterProductionCompile hook.
7+
*/
8+
export function getBuildPluginOptions({
9+
sentryBuildOptions,
10+
releaseName,
11+
distDirAbsPath,
12+
}: {
13+
sentryBuildOptions: SentryBuildOptions;
14+
releaseName: string | undefined;
15+
distDirAbsPath: string;
16+
}): SentryBuildPluginOptions {
17+
const sourcemapUploadAssets: string[] = [];
18+
const sourcemapUploadIgnore: string[] = [];
19+
20+
const filesToDeleteAfterUpload: string[] = [];
21+
22+
// We need to convert paths to posix because Glob patterns use `\` to escape
23+
// glob characters. This clashes with Windows path separators.
24+
// See: https://www.npmjs.com/package/glob
25+
const normalizedDistDirAbsPath = distDirAbsPath.replace(/\\/g, '/');
26+
27+
sourcemapUploadAssets.push(
28+
path.posix.join(normalizedDistDirAbsPath, '**'), // Next.js build output
29+
);
30+
if (sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload) {
31+
filesToDeleteAfterUpload.push(
32+
path.posix.join(normalizedDistDirAbsPath, '**', '*.js.map'),
33+
path.posix.join(normalizedDistDirAbsPath, '**', '*.mjs.map'),
34+
path.posix.join(normalizedDistDirAbsPath, '**', '*.cjs.map'),
35+
);
36+
}
37+
38+
return {
39+
authToken: sentryBuildOptions.authToken,
40+
headers: sentryBuildOptions.headers,
41+
org: sentryBuildOptions.org,
42+
project: sentryBuildOptions.project,
43+
telemetry: sentryBuildOptions.telemetry,
44+
debug: sentryBuildOptions.debug,
45+
errorHandler: sentryBuildOptions.errorHandler,
46+
reactComponentAnnotation: {
47+
...sentryBuildOptions.reactComponentAnnotation,
48+
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation,
49+
},
50+
silent: sentryBuildOptions.silent,
51+
url: sentryBuildOptions.sentryUrl,
52+
sourcemaps: {
53+
disable: sentryBuildOptions.sourcemaps?.disable,
54+
rewriteSources(source) {
55+
if (source.startsWith('webpack://_N_E/')) {
56+
return source.replace('webpack://_N_E/', '');
57+
} else if (source.startsWith('webpack://')) {
58+
return source.replace('webpack://', '');
59+
} else {
60+
return source;
61+
}
62+
},
63+
assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets,
64+
ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore,
65+
filesToDeleteAfterUpload,
66+
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps,
67+
},
68+
release:
69+
releaseName !== undefined
70+
? {
71+
inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead.
72+
name: releaseName,
73+
create: sentryBuildOptions.release?.create,
74+
finalize: sentryBuildOptions.release?.finalize,
75+
dist: sentryBuildOptions.release?.dist,
76+
vcsRemote: sentryBuildOptions.release?.vcsRemote,
77+
setCommits: sentryBuildOptions.release?.setCommits,
78+
deploy: sentryBuildOptions.release?.deploy,
79+
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release,
80+
}
81+
: {
82+
inject: false,
83+
create: false,
84+
finalize: false,
85+
},
86+
bundleSizeOptimizations: {
87+
...sentryBuildOptions.bundleSizeOptimizations,
88+
},
89+
_metaOptions: {
90+
loggerPrefixOverride: '[@sentry/nextjs]',
91+
telemetry: {
92+
metaFramework: 'nextjs',
93+
},
94+
},
95+
...sentryBuildOptions.unstable_sentryWebpackPluginOptions,
96+
};
97+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core';
2+
import { loadModule } from '@sentry/core';
3+
import { getBuildPluginOptions } from './getBuildPluginOptions';
4+
import type { SentryBuildOptions } from './types';
5+
6+
/**
7+
* This function is called by Next.js after the production build is complete.
8+
* It is used to upload sourcemaps to Sentry.
9+
*/
10+
export async function handleRunAfterProductionCompile(
11+
{ releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' },
12+
sentryBuildOptions: SentryBuildOptions,
13+
): Promise<void> {
14+
// We don't want to do anything for webpack at this point because the plugin already handles this
15+
// TODO: Actually implement this for webpack as well
16+
if (buildTool === 'webpack') {
17+
return;
18+
}
19+
20+
if (sentryBuildOptions.debug) {
21+
// eslint-disable-next-line no-console
22+
console.debug('[@sentry/nextjs] Running runAfterProductionCompile logic.');
23+
}
24+
25+
const { createSentryBuildPluginManager } =
26+
loadModule<{ createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType }>(
27+
'@sentry/bundler-plugin-core',
28+
module,
29+
) ?? {};
30+
31+
if (!createSentryBuildPluginManager) {
32+
// eslint-disable-next-line no-console
33+
console.warn(
34+
'[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.',
35+
);
36+
return;
37+
}
38+
39+
const sentryBuildPluginManager = createSentryBuildPluginManager(
40+
getBuildPluginOptions({
41+
sentryBuildOptions,
42+
releaseName,
43+
distDirAbsPath: distDir,
44+
}),
45+
{
46+
buildTool,
47+
loggerPrefix: '[@sentry/nextjs]',
48+
},
49+
);
50+
51+
await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal();
52+
await sentryBuildPluginManager.createRelease();
53+
await sentryBuildPluginManager.injectDebugIds([distDir]);
54+
await sentryBuildPluginManager.uploadSourcemaps([distDir], {
55+
// We don't want to prepare the artifacts because we injected debug ids manually before
56+
prepareArtifacts: false,
57+
});
58+
await sentryBuildPluginManager.deleteArtifacts();
59+
}

packages/nextjs/src/config/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export type NextConfigObject = {
5252
env?: Record<string, string>;
5353
serverExternalPackages?: string[]; // next >= v15.0.0
5454
turbopack?: TurbopackOptions;
55+
compiler?: {
56+
runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise<void> | void;
57+
};
5558
};
5659

5760
export type SentryBuildOptions = {
@@ -504,6 +507,15 @@ export type SentryBuildOptions = {
504507
* Use with caution in production environments.
505508
*/
506509
_experimental?: Partial<{
510+
/**
511+
* When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads
512+
* into a single operation after turbopack builds complete, reducing build time.
513+
*
514+
* When false, use the traditional approach of uploading sourcemaps during each webpack build.
515+
*
516+
* @default false
517+
*/
518+
useRunAfterProductionCompileHook?: boolean;
507519
thirdPartyOriginStackFrames: boolean;
508520
}>;
509521
};

packages/nextjs/src/config/util.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseSemver } from '@sentry/core';
12
import * as fs from 'fs';
23
import { sync as resolveSync } from 'resolve';
34

@@ -27,3 +28,39 @@ function resolveNextjsPackageJson(): string | undefined {
2728
return undefined;
2829
}
2930
}
31+
32+
/**
33+
* Checks if the current Next.js version supports the runAfterProductionCompile hook.
34+
* This hook was introduced in Next.js 15.4.1. (https://github.com/vercel/next.js/pull/77345)
35+
*
36+
* @returns true if Next.js version is 15.4.1 or higher
37+
*/
38+
export function supportsProductionCompileHook(): boolean {
39+
const version = getNextjsVersion();
40+
if (!version) {
41+
return false;
42+
}
43+
44+
const { major, minor, patch } = parseSemver(version);
45+
46+
if (major === undefined || minor === undefined || patch === undefined) {
47+
return false;
48+
}
49+
50+
if (major > 15) {
51+
return true;
52+
}
53+
54+
// For major version 15, check if it's 15.4.1 or higher
55+
if (major === 15) {
56+
if (minor > 4) {
57+
return true;
58+
}
59+
if (minor === 4 && patch >= 1) {
60+
return true;
61+
}
62+
return false;
63+
}
64+
65+
return false;
66+
}

packages/nextjs/src/config/withSentryConfig.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getSentryRelease } from '@sentry/node';
55
import * as childProcess from 'child_process';
66
import * as fs from 'fs';
77
import * as path from 'path';
8+
import { handleRunAfterProductionCompile } from './handleRunAfterProductionCompile';
89
import { createRouteManifest } from './manifest/createRouteManifest';
910
import type { RouteManifest } from './manifest/types';
1011
import { constructTurbopackConfig } from './turbopack';
@@ -14,7 +15,7 @@ import type {
1415
NextConfigObject,
1516
SentryBuildOptions,
1617
} from './types';
17-
import { getNextjsVersion } from './util';
18+
import { getNextjsVersion, supportsProductionCompileHook } from './util';
1819
import { constructWebpackConfigFunction } from './webpack';
1920

2021
let showedExportModeTunnelWarning = false;
@@ -293,6 +294,59 @@ function getFinalConfigObject(
293294
}
294295
}
295296

297+
if (userSentryOptions?._experimental?.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) {
298+
if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) {
299+
incomingUserNextConfigObject.compiler ??= {};
300+
incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => {
301+
await handleRunAfterProductionCompile(
302+
{ releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' },
303+
userSentryOptions,
304+
);
305+
};
306+
} else if (typeof incomingUserNextConfigObject.compiler.runAfterProductionCompile === 'function') {
307+
incomingUserNextConfigObject.compiler.runAfterProductionCompile = new Proxy(
308+
incomingUserNextConfigObject.compiler.runAfterProductionCompile,
309+
{
310+
async apply(target, thisArg, argArray) {
311+
const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' };
312+
await target.apply(thisArg, argArray);
313+
await handleRunAfterProductionCompile(
314+
{ releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' },
315+
userSentryOptions,
316+
);
317+
},
318+
},
319+
);
320+
} else {
321+
// eslint-disable-next-line no-console
322+
console.warn(
323+
'[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.',
324+
);
325+
}
326+
}
327+
328+
// Enable source maps for turbopack builds
329+
if (isTurbopackSupported && isTurbopack && !userSentryOptions.sourcemaps?.disable) {
330+
// Only set if not already configured by user
331+
if (incomingUserNextConfigObject.productionBrowserSourceMaps === undefined) {
332+
// eslint-disable-next-line no-console
333+
console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.');
334+
incomingUserNextConfigObject.productionBrowserSourceMaps = true;
335+
336+
// Enable source map deletion if not explicitly disabled
337+
if (userSentryOptions.sourcemaps?.deleteSourcemapsAfterUpload === undefined) {
338+
// eslint-disable-next-line no-console
339+
console.warn(
340+
'[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.',
341+
);
342+
userSentryOptions.sourcemaps = {
343+
...userSentryOptions.sourcemaps,
344+
deleteSourcemapsAfterUpload: true,
345+
};
346+
}
347+
}
348+
}
349+
296350
return {
297351
...incomingUserNextConfigObject,
298352
...(nextMajor && nextMajor >= 15

0 commit comments

Comments
 (0)