Skip to content

feat(nextjs): Inject manifest into client for webpack builds #16857

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

Merged
merged 19 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
196 changes: 196 additions & 0 deletions packages/nextjs/src/config/manifest/buildManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import * as fs from 'fs';
import * as path from 'path';

export type RouteInfo = {
path: string;
dynamic: boolean;
pattern?: string;
paramNames?: string[];
};

export type RouteManifest = {
dynamic: RouteInfo[];
static: RouteInfo[];
};

export type CreateRouteManifestOptions = {
// For starters we only support app router
appDirPath?: string;
};

let manifestCache: RouteManifest | null = null;
let lastAppDirPath: string | null = null;

function isPageFile(filename: string): boolean {
return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js';
}

function isRouteGroup(name: string): boolean {
return name.startsWith('(') && name.endsWith(')');
}

function getDynamicRouteSegment(name: string): string {
if (name.startsWith('[[...') && name.endsWith(']]')) {
// Optional catchall: [[...param]]
const paramName = name.slice(5, -2); // Remove [[... and ]]
return `:${paramName}*?`; // Mark with ? as optional
} else if (name.startsWith('[...') && name.endsWith(']')) {
// Required catchall: [...param]
const paramName = name.slice(4, -1); // Remove [... and ]
return `:${paramName}*`;
} else {
// Regular dynamic: [param]
return `:${name.slice(1, -1)}`;
}
}

function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } {
const segments = routePath.split('/').filter(Boolean);
const regexSegments: string[] = [];
const paramNames: string[] = [];
let hasOptionalCatchall = false;

for (const segment of segments) {
if (segment.startsWith(':')) {
const paramName = segment.substring(1);

if (paramName.endsWith('*?')) {
// Optional catchall: matches zero or more segments
const cleanParamName = paramName.slice(0, -2);
paramNames.push(cleanParamName);
// Handling this special case in pattern construction below
hasOptionalCatchall = true;
} else if (paramName.endsWith('*')) {
// Required catchall: matches one or more segments
const cleanParamName = paramName.slice(0, -1);
paramNames.push(cleanParamName);
regexSegments.push('(.+)');
} else {
// Regular dynamic segment
paramNames.push(paramName);
regexSegments.push('([^/]+)');
}
} else {
// Static segment
regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
}
}

let pattern: string;
if (hasOptionalCatchall) {
// For optional catchall, make the trailing slash and segments optional
// This allows matching both /catchall and /catchall/anything
const staticParts = regexSegments.join('/');
pattern = `^/${staticParts}(?:/(.*))?$`;
} else {
pattern = `^/${regexSegments.join('/')}$`;
}

return { pattern, paramNames };
}

function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] {
const routes: RouteInfo[] = [];

try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const pageFile = entries.some(entry => isPageFile(entry.name));

if (pageFile) {
const routePath = basePath || '/';
const isDynamic = routePath.includes(':');

if (isDynamic) {
const { pattern, paramNames } = buildRegexForDynamicRoute(routePath);
routes.push({
path: routePath,
dynamic: true,
pattern,
paramNames,
});
} else {
routes.push({
path: routePath,
dynamic: false,
});
}
}

for (const entry of entries) {
if (entry.isDirectory()) {
const fullPath = path.join(dir, entry.name);

if (isRouteGroup(entry.name)) {
// Route groups don't affect the URL, just scan them
const subRoutes = scanAppDirectory(fullPath, basePath);
routes.push(...subRoutes);
continue;
}

const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']');
let routeSegment: string;

if (isDynamic) {
routeSegment = getDynamicRouteSegment(entry.name);
} else {
routeSegment = entry.name;
}

const newBasePath = `${basePath}/${routeSegment}`;
const subRoutes = scanAppDirectory(fullPath, newBasePath);
routes.push(...subRoutes);
}
}
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Error building route manifest:', error);
}

return routes;
}

/**
* Returns a route manifest for the given app directory
*/
export function createRouteManifest(options?: CreateRouteManifestOptions): RouteManifest {
let targetDir: string | undefined;

if (options?.appDirPath) {
targetDir = options.appDirPath;
} else {
const projectDir = process.cwd();
const maybeAppDirPath = path.join(projectDir, 'app');
const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app');

if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) {
targetDir = maybeAppDirPath;
} else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) {
targetDir = maybeSrcAppDirPath;
}
}

if (!targetDir) {
return {
dynamic: [],
static: [],
};
}

// Check if we can use cached version
if (manifestCache && lastAppDirPath === targetDir) {
return manifestCache;
}

const routes = scanAppDirectory(targetDir);

const manifest: RouteManifest = {
dynamic: routes.filter(route => route.dynamic),
static: routes.filter(route => !route.dynamic),
};

// set cache
manifestCache = manifest;
lastAppDirPath = targetDir;

return manifest;
}
7 changes: 7 additions & 0 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,13 @@ export type SentryBuildOptions = {
*/
suppressOnRouterTransitionStartWarning?: boolean;

/**
* Enables manifest injection.
*
* Defaults to `true`.
*/
enableManifest?: boolean;

/**
* Contains a set of experimental flags that might change in future releases. These flags enable
* features that are still in development and may be modified, renamed, or removed without notice.
Expand Down
6 changes: 5 additions & 1 deletion packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { sync as resolveSync } from 'resolve';
import type { VercelCronsConfig } from '../common/types';
import type { RouteManifest } from './manifest/buildManifest';
// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our
// circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306.
import type {
Expand Down Expand Up @@ -43,6 +44,7 @@ export function constructWebpackConfigFunction(
userNextConfig: NextConfigObject = {},
userSentryOptions: SentryBuildOptions = {},
releaseName: string | undefined,
routeManifest: RouteManifest | undefined,
): WebpackConfigFunction {
// Will be called by nextjs and passed its default webpack configuration and context data about the build (whether
// we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that
Expand Down Expand Up @@ -88,7 +90,7 @@ export function constructWebpackConfigFunction(
const newConfig = setUpModuleRules(rawNewConfig);

// Add a loader which will inject code that sets global values
addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName);
addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName, routeManifest);

addOtelWarningIgnoreRule(newConfig);

Expand Down Expand Up @@ -686,6 +688,7 @@ function addValueInjectionLoader(
userSentryOptions: SentryBuildOptions,
buildContext: BuildContext,
releaseName: string | undefined,
routeManifest: RouteManifest | undefined,
): void {
const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || '';

Expand Down Expand Up @@ -727,6 +730,7 @@ function addValueInjectionLoader(
_sentryExperimentalThirdPartyOriginStackFrames: userSentryOptions._experimental?.thirdPartyOriginStackFrames
? 'true'
: undefined,
_sentryRouteManifest: JSON.stringify(routeManifest),
};

if (buildContext.isServer) {
Expand Down
14 changes: 13 additions & 1 deletion packages/nextjs/src/config/withSentryConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { getSentryRelease } from '@sentry/node';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import type { RouteManifest } from './manifest/buildManifest';
import { createRouteManifest } from './manifest/buildManifest';
import type {
ExportedNextConfig as NextConfig,
NextConfigFunction,
Expand Down Expand Up @@ -141,6 +143,11 @@ function getFinalConfigObject(
}
}

let routeManifest: RouteManifest | undefined;
if (userSentryOptions.enableManifest !== false) {
routeManifest = createRouteManifest();
}

setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName);

const nextJsVersion = getNextjsVersion();
Expand Down Expand Up @@ -300,7 +307,12 @@ function getFinalConfigObject(
],
},
}),
webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName),
webpack: constructWebpackConfigFunction(
incomingUserNextConfigObject,
userSentryOptions,
releaseName,
routeManifest,
),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// beep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Ciao
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import path from 'path';
import { describe, expect, test } from 'vitest';
import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest';

describe('catchall', () => {
const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') });

test('should generate a manifest with catchall route', () => {
expect(manifest).toEqual({
dynamic: [
{
path: '/catchall/:path*?',
dynamic: true,
pattern: '^/catchall(?:/(.*))?$',
paramNames: ['path'],
},
],
static: [{ path: '/', dynamic: false }],
});
});

test('should generate correct pattern for catchall route', () => {
const regex = new RegExp(manifest.dynamic[0]?.pattern ?? '');
expect(regex.test('/catchall/123')).toBe(true);
expect(regex.test('/catchall/abc')).toBe(true);
expect(regex.test('/catchall/123/456')).toBe(true);
expect(regex.test('/catchall/123/abc/789')).toBe(true);
expect(regex.test('/catchall/')).toBe(true);
expect(regex.test('/catchall')).toBe(true);
expect(regex.test('/123/catchall/123')).toBe(false);
expect(regex.test('/')).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// beep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Ciao
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Hola
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// User profile page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Post detail page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// User settings page
Loading
Loading