Skip to content

Commit 5de3eb7

Browse files
committed
use runAfterProductionCompile for sourcemaps
1 parent ac57eb1 commit 5de3eb7

File tree

8 files changed

+308
-25
lines changed

8 files changed

+308
-25
lines changed

packages/nextjs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,15 @@
8080
"@opentelemetry/semantic-conventions": "^1.34.0",
8181
"@rollup/plugin-commonjs": "28.0.1",
8282
"@sentry-internal/browser-utils": "10.1.0",
83+
"@sentry/bundler-plugin-core": "^4.0.2",
8384
"@sentry/core": "10.1.0",
8485
"@sentry/node": "10.1.0",
8586
"@sentry/opentelemetry": "10.1.0",
8687
"@sentry/react": "10.1.0",
8788
"@sentry/vercel-edge": "10.1.0",
8889
"@sentry/webpack-plugin": "^4.0.2",
8990
"chalk": "3.0.0",
91+
"glob": "^11.0.3",
9092
"resolve": "1.22.8",
9193
"rollup": "^4.35.0",
9294
"stacktrace-parser": "^0.1.10"
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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core';
2+
import { loadModule } from '@sentry/core';
3+
import { glob } from 'glob';
4+
import { getBuildPluginOptions } from './getBuildPluginOptions';
5+
import type { SentryBuildOptions } from './types';
6+
7+
/**
8+
* This function is called by Next.js after the production build is complete.
9+
* It is used to upload sourcemaps to Sentry.
10+
*/
11+
export async function handleRunAfterProductionCompile(
12+
{ releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' },
13+
sentryBuildOptions: SentryBuildOptions,
14+
): Promise<void> {
15+
if (sentryBuildOptions.debug) {
16+
// eslint-disable-next-line no-console
17+
console.debug('[@sentry/nextjs] Running runAfterProductionCompile logic.');
18+
}
19+
20+
const { createSentryBuildPluginManager } =
21+
loadModule<{ createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType }>(
22+
'@sentry/bundler-plugin-core',
23+
module,
24+
) ?? {};
25+
26+
if (!createSentryBuildPluginManager) {
27+
// eslint-disable-next-line no-console
28+
console.warn(
29+
'[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.',
30+
);
31+
return;
32+
}
33+
34+
const sentryBuildPluginManager = createSentryBuildPluginManager(
35+
getBuildPluginOptions({
36+
sentryBuildOptions,
37+
releaseName,
38+
distDirAbsPath: distDir,
39+
}),
40+
{
41+
buildTool,
42+
loggerPrefix: '[@sentry/nextjs]',
43+
},
44+
);
45+
46+
const buildArtifactsPromise = glob(
47+
['/**/*.js', '/**/*.mjs', '/**/*.cjs', '/**/*.js.map', '/**/*.mjs.map', '/**/*.cjs.map'].map(
48+
q => `${q}?(\\?*)?(#*)`, // We want to allow query and hashes strings at the end of files
49+
),
50+
{
51+
root: distDir,
52+
absolute: true,
53+
nodir: true,
54+
},
55+
);
56+
57+
await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal();
58+
await sentryBuildPluginManager.createRelease();
59+
// 🔜 await sentryBuildPluginManager.injectDebugIds();
60+
await sentryBuildPluginManager.uploadSourcemaps(await buildArtifactsPromise);
61+
await sentryBuildPluginManager.deleteArtifacts();
62+
}

packages/nextjs/src/config/types.ts

Lines changed: 13 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 = {
@@ -498,6 +501,16 @@ export type SentryBuildOptions = {
498501
*/
499502
disableSentryWebpackConfig?: boolean;
500503

504+
/**
505+
* When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads
506+
* into a single operation after all webpack/turbopack builds complete, reducing build time.
507+
*
508+
* When false, use the traditional approach of uploading sourcemaps during each webpack build.
509+
*
510+
* @default false
511+
*/
512+
useRunAfterProductionCompileHook?: boolean;
513+
501514
/**
502515
* Contains a set of experimental flags that might change in future releases. These flags enable
503516
* features that are still in development and may be modified, renamed, or removed without notice.

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/webpackPluginOptions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ export function getWebpackPluginOptions(
7070
silent: sentryBuildOptions.silent,
7171
url: sentryBuildOptions.sentryUrl,
7272
sourcemaps: {
73+
// if the user has enabled the runAfterProductionCompileHook, we handle sourcemap uploads a later step
7374
disable: sentryBuildOptions.sourcemaps?.disable,
75+
// TODO: disable: sentryBuildOptions.useRunAfterProductionCompileHook
76+
// ? 'disable-upload'
77+
// : sentryBuildOptions.sourcemaps?.disable,
7478
rewriteSources(source) {
7579
if (source.startsWith('webpack://_N_E/')) {
7680
return source.replace('webpack://_N_E/', '');
@@ -95,7 +99,8 @@ export function getWebpackPluginOptions(
9599
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps,
96100
},
97101
release:
98-
releaseName !== undefined
102+
// if the user has enabled the runAfterProductionCompileHook, we handle release creation a later step
103+
releaseName !== undefined && !sentryBuildOptions.useRunAfterProductionCompileHook
99104
? {
100105
inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead.
101106
name: releaseName,

packages/nextjs/src/config/withSentryConfig.ts

Lines changed: 33 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,37 @@ function getFinalConfigObject(
293294
}
294295
}
295296

297+
if (userSentryOptions.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+
296328
return {
297329
...incomingUserNextConfigObject,
298330
...(nextMajor && nextMajor >= 15

0 commit comments

Comments
 (0)