Skip to content

Commit 5da93d8

Browse files
authored
feat(nextjs): Add routeManifestInjection option to exclude routes from client bundle (#18798)
Added a new `routeManifestInjection` configuration option that allows users to exclude specific routes from the route manifest injected into the client bundle. This addresses concerns about sensitive or unreleased route patterns being exposed in the client-side code. This also deprecated `disableManifestInjection` option since it would be possible to have conflicting options present which wouldn't be a great DX. Users can disable it entirely by passing `false`, otherwise they can use an object with an `exclude` property. The property can be an array of string/regex values, or a predicate function. The value typings prevent disabling the manifest and excluding it at the same time, also deprecation annotations and build-time warnings should point users towards the new option. ```ts // Disable route manifest injection entirely withSentryConfig(nextConfig, { routeManifestInjection: false }) // Exclude specific routes withSentryConfig(nextConfig, { routeManifestInjection: { exclude: [ '/admin', // Exact match /^\/internal\//, // Regex: routes starting with /internal/ /\/secret-/, // Regex: routes containing /secret- ] } }) // Exclude using a function withSentryConfig(nextConfig, { routeManifestInjection: { exclude: (route) => route.includes('hidden') } }) ``` closes #18713
1 parent 419a0e6 commit 5da93d8

File tree

3 files changed

+267
-18
lines changed

3 files changed

+267
-18
lines changed

packages/nextjs/src/config/types.ts

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ export type SentryBuildOptions = {
491491
* A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components.
492492
*/
493493
ignoredComponents?: string[];
494-
};
494+
}; // TODO(v11): remove this option
495495

496496
/**
497497
* Options to be passed directly to the Sentry Webpack Plugin (`@sentry/webpack-plugin`) that ships with the Sentry Next.js SDK.
@@ -500,7 +500,7 @@ export type SentryBuildOptions = {
500500
* Please note that this option is unstable and may change in a breaking way in any release.
501501
* @deprecated Use `webpack.unstable_sentryWebpackPluginOptions` instead.
502502
*/
503-
unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions;
503+
unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; // TODO(v11): remove this option
504504

505505
/**
506506
* Include Next.js-internal code and code from dependencies when uploading source maps.
@@ -522,19 +522,19 @@ export type SentryBuildOptions = {
522522
* Defaults to `true`.
523523
* @deprecated Use `webpack.autoInstrumentServerFunctions` instead.
524524
*/
525-
autoInstrumentServerFunctions?: boolean;
525+
autoInstrumentServerFunctions?: boolean; // TODO(v11): remove this option
526526

527527
/**
528528
* Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`.
529529
* @deprecated Use `webpack.autoInstrumentMiddleware` instead.
530530
*/
531-
autoInstrumentMiddleware?: boolean;
531+
autoInstrumentMiddleware?: boolean; // TODO(v11): remove this option
532532

533533
/**
534534
* Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`.
535535
* @deprecated Use `webpack.autoInstrumentAppDirectory` instead.
536536
*/
537-
autoInstrumentAppDirectory?: boolean;
537+
autoInstrumentAppDirectory?: boolean; // TODO(v11): remove this option
538538

539539
/**
540540
* Exclude certain serverside API routes or pages from being instrumented with Sentry during build-time. This option
@@ -567,7 +567,7 @@ export type SentryBuildOptions = {
567567
*
568568
* @deprecated Use `webpack.treeshake.removeDebugLogging` instead.
569569
*/
570-
disableLogger?: boolean;
570+
disableLogger?: boolean; // TODO(v11): remove this option
571571

572572
/**
573573
* Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`.
@@ -576,7 +576,7 @@ export type SentryBuildOptions = {
576576
*
577577
* @deprecated Use `webpack.automaticVercelMonitors` instead.
578578
*/
579-
automaticVercelMonitors?: boolean;
579+
automaticVercelMonitors?: boolean; // TODO(v11): remove this option
580580

581581
/**
582582
* When an error occurs during release creation or sourcemaps upload, the plugin will call this function.
@@ -603,20 +603,59 @@ export type SentryBuildOptions = {
603603
/**
604604
* Disables automatic injection of the route manifest into the client bundle.
605605
*
606+
* @deprecated Use `routeManifestInjection: false` instead.
607+
*
608+
* @default false
609+
*/
610+
disableManifestInjection?: boolean; // TODO(v11): remove this option
611+
612+
/**
613+
* Options for the route manifest injection feature.
614+
*
606615
* The route manifest is a build-time generated mapping of your Next.js App Router
607616
* routes that enables Sentry to group transactions by parameterized route names
608617
* (e.g., `/users/:id` instead of `/users/123`, `/users/456`, etc.).
609618
*
610-
* **Disable this option if:**
611-
* - You want to minimize client bundle size
612-
* - You're experiencing build issues related to route scanning
613-
* - You're using custom routing that the scanner can't detect
614-
* - You prefer raw URLs in transaction names
615-
* - You're only using Pages Router (this feature is only supported in the App Router)
619+
* Set to `false` to disable route manifest injection entirely.
616620
*
617-
* @default false
621+
* @example
622+
* ```js
623+
* // Disable route manifest injection
624+
* routeManifestInjection: false
625+
*
626+
* // Exclude specific routes
627+
* routeManifestInjection: {
628+
* exclude: [
629+
* '/admin', // Exact match
630+
* /^\/internal\//, // Regex: all routes starting with /internal/
631+
* /\/secret-/, // Regex: any route containing /secret-
632+
* ]
633+
* }
634+
*
635+
* // Exclude using a function
636+
* routeManifestInjection: {
637+
* exclude: (route) => route.includes('hidden')
638+
* }
639+
* ```
618640
*/
619-
disableManifestInjection?: boolean;
641+
routeManifestInjection?:
642+
| false
643+
| {
644+
/**
645+
* Exclude specific routes from the route manifest.
646+
*
647+
* Use this option to prevent certain routes from being included in the client bundle's
648+
* route manifest. This is useful for:
649+
* - Hiding confidential or unreleased feature routes
650+
* - Excluding internal/admin routes you don't want exposed
651+
* - Reducing bundle size by omitting rarely-used routes
652+
*
653+
* Can be specified as:
654+
* - An array of strings (exact match) or RegExp patterns
655+
* - A function that receives a route path and returns `true` to exclude it
656+
*/
657+
exclude?: Array<string | RegExp> | ((route: string) => boolean);
658+
};
620659

621660
/**
622661
* Disables automatic injection of Sentry's Webpack configuration.
@@ -630,7 +669,7 @@ export type SentryBuildOptions = {
630669
*
631670
* @default false
632671
*/
633-
disableSentryWebpackConfig?: boolean;
672+
disableSentryWebpackConfig?: boolean; // TODO(v11): remove this option
634673

635674
/**
636675
* When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads

packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseSemver } from '@sentry/core';
1+
import { isMatchingPattern, parseSemver } from '@sentry/core';
22
import { getSentryRelease } from '@sentry/node';
33
import { createRouteManifest } from '../manifest/createRouteManifest';
44
import type { RouteManifest } from '../manifest/types';
@@ -89,13 +89,59 @@ export function maybeCreateRouteManifest(
8989
incomingUserNextConfigObject: NextConfigObject,
9090
userSentryOptions: SentryBuildOptions,
9191
): RouteManifest | undefined {
92+
// Handle deprecated option with warning
93+
// eslint-disable-next-line deprecation/deprecation
9294
if (userSentryOptions.disableManifestInjection) {
95+
// eslint-disable-next-line no-console
96+
console.warn(
97+
'[@sentry/nextjs] The `disableManifestInjection` option is deprecated. Use `routeManifestInjection: false` instead.',
98+
);
99+
}
100+
101+
// If explicitly disabled, skip
102+
if (userSentryOptions.routeManifestInjection === false) {
93103
return undefined;
94104
}
95105

96-
return createRouteManifest({
106+
// Still check the deprecated option if the new option is not set
107+
// eslint-disable-next-line deprecation/deprecation
108+
if (userSentryOptions.routeManifestInjection === undefined && userSentryOptions.disableManifestInjection) {
109+
return undefined;
110+
}
111+
112+
const manifest = createRouteManifest({
97113
basePath: incomingUserNextConfigObject.basePath,
98114
});
115+
116+
// Apply route exclusion filter if configured
117+
const excludeFilter = userSentryOptions.routeManifestInjection?.exclude;
118+
return filterRouteManifest(manifest, excludeFilter);
119+
}
120+
121+
type ExcludeFilter = ((route: string) => boolean) | (string | RegExp)[] | undefined;
122+
123+
/**
124+
* Filters routes from the manifest based on the exclude filter.
125+
* (Exported only for testing)
126+
*/
127+
export function filterRouteManifest(manifest: RouteManifest, excludeFilter: ExcludeFilter): RouteManifest {
128+
if (!excludeFilter) {
129+
return manifest;
130+
}
131+
132+
const shouldExclude = (route: string): boolean => {
133+
if (typeof excludeFilter === 'function') {
134+
return excludeFilter(route);
135+
}
136+
137+
return excludeFilter.some(pattern => isMatchingPattern(route, pattern));
138+
};
139+
140+
return {
141+
staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)),
142+
dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)),
143+
isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)),
144+
};
99145
}
100146

101147
/**
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { RouteManifest } from '../../../src/config/manifest/types';
3+
import { filterRouteManifest } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils';
4+
5+
describe('routeManifestInjection.exclude', () => {
6+
const mockManifest: RouteManifest = {
7+
staticRoutes: [
8+
{ path: '/' },
9+
{ path: '/about' },
10+
{ path: '/admin' },
11+
{ path: '/admin/dashboard' },
12+
{ path: '/internal/secret' },
13+
{ path: '/public/page' },
14+
],
15+
dynamicRoutes: [
16+
{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] },
17+
{ path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] },
18+
{ path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] },
19+
],
20+
isrRoutes: ['/blog', '/admin/reports', '/internal/stats'],
21+
};
22+
23+
describe('with no filter', () => {
24+
it('should return manifest unchanged', () => {
25+
const result = filterRouteManifest(mockManifest, undefined);
26+
expect(result).toEqual(mockManifest);
27+
});
28+
});
29+
30+
describe('with string patterns', () => {
31+
it('should exclude routes containing the string pattern (substring match)', () => {
32+
const result = filterRouteManifest(mockManifest, ['/admin']);
33+
34+
// All routes containing '/admin' are excluded
35+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
36+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
37+
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
38+
});
39+
40+
it('should exclude routes matching multiple string patterns', () => {
41+
const result = filterRouteManifest(mockManifest, ['/about', '/blog']);
42+
43+
expect(result.staticRoutes.map(r => r.path)).toEqual([
44+
'/',
45+
'/admin',
46+
'/admin/dashboard',
47+
'/internal/secret',
48+
'/public/page',
49+
]);
50+
expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']);
51+
});
52+
53+
it('should match substrings anywhere in the route', () => {
54+
// 'secret' matches '/internal/secret' and '/secret-feature/:id'
55+
const result = filterRouteManifest(mockManifest, ['secret']);
56+
57+
expect(result.staticRoutes.map(r => r.path)).toEqual([
58+
'/',
59+
'/about',
60+
'/admin',
61+
'/admin/dashboard',
62+
'/public/page',
63+
]);
64+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']);
65+
});
66+
});
67+
68+
describe('with regex patterns', () => {
69+
it('should exclude routes matching regex', () => {
70+
const result = filterRouteManifest(mockManifest, [/^\/admin/]);
71+
72+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
73+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
74+
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
75+
});
76+
77+
it('should support multiple regex patterns', () => {
78+
const result = filterRouteManifest(mockManifest, [/^\/admin/, /^\/internal/]);
79+
80+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']);
81+
expect(result.isrRoutes).toEqual(['/blog']);
82+
});
83+
84+
it('should support partial regex matches', () => {
85+
const result = filterRouteManifest(mockManifest, [/secret/]);
86+
87+
expect(result.staticRoutes.map(r => r.path)).toEqual([
88+
'/',
89+
'/about',
90+
'/admin',
91+
'/admin/dashboard',
92+
'/public/page',
93+
]);
94+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']);
95+
});
96+
97+
it('should handle case-insensitive regex', () => {
98+
const result = filterRouteManifest(mockManifest, [/ADMIN/i]);
99+
100+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
101+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
102+
});
103+
});
104+
105+
describe('with mixed patterns', () => {
106+
it('should support both strings and regex', () => {
107+
const result = filterRouteManifest(mockManifest, ['/about', /^\/admin/]);
108+
109+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/internal/secret', '/public/page']);
110+
});
111+
});
112+
113+
describe('with function filter', () => {
114+
it('should exclude routes where function returns true', () => {
115+
const result = filterRouteManifest(mockManifest, (route: string) => route.includes('admin'));
116+
117+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
118+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
119+
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
120+
});
121+
122+
it('should support complex filter logic', () => {
123+
const result = filterRouteManifest(mockManifest, (route: string) => {
124+
// Exclude anything with "secret" or "internal" or admin routes
125+
return route.includes('secret') || route.includes('internal') || route.startsWith('/admin');
126+
});
127+
128+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']);
129+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']);
130+
expect(result.isrRoutes).toEqual(['/blog']);
131+
});
132+
});
133+
134+
describe('edge cases', () => {
135+
it('should handle empty manifest', () => {
136+
const emptyManifest: RouteManifest = {
137+
staticRoutes: [],
138+
dynamicRoutes: [],
139+
isrRoutes: [],
140+
};
141+
142+
const result = filterRouteManifest(emptyManifest, [/admin/]);
143+
expect(result).toEqual(emptyManifest);
144+
});
145+
146+
it('should handle filter that excludes everything', () => {
147+
const result = filterRouteManifest(mockManifest, () => true);
148+
149+
expect(result.staticRoutes).toEqual([]);
150+
expect(result.dynamicRoutes).toEqual([]);
151+
expect(result.isrRoutes).toEqual([]);
152+
});
153+
154+
it('should handle filter that excludes nothing', () => {
155+
const result = filterRouteManifest(mockManifest, () => false);
156+
expect(result).toEqual(mockManifest);
157+
});
158+
159+
it('should handle empty filter array', () => {
160+
const result = filterRouteManifest(mockManifest, []);
161+
expect(result).toEqual(mockManifest);
162+
});
163+
});
164+
});

0 commit comments

Comments
 (0)