Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
179 changes: 112 additions & 67 deletions packages/vinext/src/config/config-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
import type { NextRedirect, NextRewrite, NextHeader, HasCondition } from "./next-config.js";
import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-request-headers.js";

export type CompiledConfigPattern = {
re: RegExp;
paramNames: string[];
};

/**
* Cache for compiled regex patterns in matchConfigPattern.
*
Expand All @@ -21,7 +26,7 @@ import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-
* Value is `null` when safeRegExp rejected the pattern (ReDoS risk), so we
* skip it on subsequent requests too without re-running the scanner.
*/
const _compiledPatternCache = new Map<string, { re: RegExp; paramNames: string[] } | null>();
const _compiledPatternCache = new Map<string, CompiledConfigPattern | null>();

/**
* Cache for compiled header source regexes in matchHeaders.
Expand Down Expand Up @@ -638,6 +643,83 @@ function extractConstraint(str: string, re: RegExp): string | null {
return str.slice(start, i - 1);
}

function usesRegexBranch(pattern: string): boolean {
return (
pattern.includes("(") ||
pattern.includes("\\") ||
/:[\w-]+[*+][^/]/.test(pattern) ||
/:[\w-]+\./.test(pattern)
);
}

function execCompiledConfigPattern(
pathname: string,
compiled: CompiledConfigPattern,
): Record<string, string> | null {
const match = compiled.re.exec(pathname);
if (!match) return null;
const params: Record<string, string> = Object.create(null);
for (let i = 0; i < compiled.paramNames.length; i++) {
params[compiled.paramNames[i]] = match[i + 1] ?? "";
}
return params;
}

/**
* Pre-compile a Next.js config pattern into a RegExp + param-name list.
*
* Returns `null` for simple segment-based patterns (no groups, backslashes,
* catch-all suffixes, or dot-after-param) — these are already fast via the
* runtime segment matcher and don't benefit from regex compilation. Also
* returns `null` if the compiled regex is rejected by `safeRegExp`.
*/
export function compileConfigPattern(pattern: string): CompiledConfigPattern | null {
if (!usesRegexBranch(pattern)) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting: compileConfigPattern returns null for "simple" patterns (no regex groups, no backslashes, no catch-all suffixes, no dot after param). This means the precompiled arrays will contain null for these patterns, and the matching functions correctly fall back to matchConfigPattern at runtime.

This is a sensible design — simple segment-based patterns are already fast (string split + compare), so there's no need to precompile them into regexes. The doc comment on the exported function should mention this return-null behavior since callers need to understand that null means "use the runtime matcher" rather than "pattern is invalid."


try {
const paramNames: string[] = [];
const tokenRe = /:([\w-]+)|[.]|[^:.]+/g; // lgtm[js/redos] — alternatives are non-overlapping (`:` and `.` excluded from `[^:.]+`)
let regexStr = "";
let tok: RegExpExecArray | null;

while ((tok = tokenRe.exec(pattern)) !== null) {
if (tok[1] !== undefined) {
const name = tok[1];
const rest = pattern.slice(tokenRe.lastIndex);
if (rest.startsWith("*") || rest.startsWith("+")) {
const quantifier = rest[0];
tokenRe.lastIndex += 1;
const constraint = extractConstraint(pattern, tokenRe);
paramNames.push(name);
if (constraint !== null) {
regexStr += `(${constraint})`;
} else {
regexStr += quantifier === "*" ? "(.*)" : "(.+)";
}
} else {
const constraint = extractConstraint(pattern, tokenRe);
paramNames.push(name);
regexStr += constraint !== null ? `(${constraint})` : "([^/]+)";
}
} else if (tok[0] === ".") {
regexStr += "\\.";
} else {
regexStr += tok[0];
}
}

const re = safeRegExp("^" + regexStr + "$");
return re ? { re, paramNames } : null;
} catch {
return null;
}
}

export function compileHeaderSourcePattern(source: string): RegExp | null {
const escaped = escapeHeaderSource(source);
return safeRegExp("^" + escaped + "$");
}

/**
* Match a Next.js config pattern (from redirects/rewrites sources) against a pathname.
* Returns matched params or null.
Expand All @@ -661,68 +743,15 @@ export function matchConfigPattern(
// The last condition catches simple params with literal suffixes (e.g. "/:slug.md")
// where the param name is followed by a dot — the simple matcher would treat
// "slug.md" as the param name and match any single segment regardless of suffix.
if (
pattern.includes("(") ||
pattern.includes("\\") ||
/:[\w-]+[*+][^/]/.test(pattern) ||
/:[\w-]+\./.test(pattern)
) {
if (usesRegexBranch(pattern)) {
try {
// Look up the compiled regex in the module-level cache. Patterns come
// from next.config.js and are static, so we only need to compile each
// one once across the lifetime of the worker/server process.
let compiled = _compiledPatternCache.get(pattern);
if (compiled === undefined) {
// Cache miss — compile the pattern now and store the result.
// Param names may contain hyphens (e.g. :auth-method, :sign-in).
const paramNames: string[] = [];
// Single-pass conversion with procedural suffix handling. The tokenizer
// matches only simple, non-overlapping tokens; quantifier/constraint
// suffixes after :param are consumed procedurally to avoid polynomial
// backtracking in the regex engine.
let regexStr = "";
const tokenRe = /:([\w-]+)|[.]|[^:.]+/g; // lgtm[js/redos] — alternatives are non-overlapping (`:` and `.` excluded from `[^:.]+`)
let tok: RegExpExecArray | null;
while ((tok = tokenRe.exec(pattern)) !== null) {
if (tok[1] !== undefined) {
const name = tok[1];
const rest = pattern.slice(tokenRe.lastIndex);
// Check for quantifier (* or +) with optional constraint
if (rest.startsWith("*") || rest.startsWith("+")) {
const quantifier = rest[0];
tokenRe.lastIndex += 1;
const constraint = extractConstraint(pattern, tokenRe);
paramNames.push(name);
if (constraint !== null) {
regexStr += `(${constraint})`;
} else {
regexStr += quantifier === "*" ? "(.*)" : "(.+)";
}
} else {
// Check for inline constraint without quantifier
const constraint = extractConstraint(pattern, tokenRe);
paramNames.push(name);
regexStr += constraint !== null ? `(${constraint})` : "([^/]+)";
}
} else if (tok[0] === ".") {
regexStr += "\\.";
} else {
regexStr += tok[0];
}
}
const re = safeRegExp("^" + regexStr + "$");
// Store null for rejected patterns so we don't re-run isSafeRegex.
compiled = re ? { re, paramNames } : null;
compiled = compileConfigPattern(pattern);
_compiledPatternCache.set(pattern, compiled);
}
if (!compiled) return null;
const match = compiled.re.exec(pathname);
if (!match) return null;
const params: Record<string, string> = Object.create(null);
for (let i = 0; i < compiled.paramNames.length; i++) {
params[compiled.paramNames[i]] = match[i + 1] ?? "";
}
return params;
return execCompiledConfigPattern(pathname, compiled);
} catch {
// Fall through to segment-based matching
}
Expand Down Expand Up @@ -805,6 +834,7 @@ export function matchRedirect(
pathname: string,
redirects: NextRedirect[],
ctx: RequestContext,
compiledPatterns?: Array<CompiledConfigPattern | null>,
): { destination: string; permanent: boolean } | null {
if (redirects.length === 0) return null;

Expand Down Expand Up @@ -892,7 +922,10 @@ export function matchRedirect(
// the locale-static match wins. Stop scanning.
break;
}
const params = matchConfigPattern(pathname, redirect.source);
const compiled = compiledPatterns?.[origIdx];
const params = compiled
? execCompiledConfigPattern(pathname, compiled)
: matchConfigPattern(pathname, redirect.source);
if (params) {
const conditionParams =
redirect.has || redirect.missing
Expand Down Expand Up @@ -925,9 +958,14 @@ export function matchRewrite(
pathname: string,
rewrites: NextRewrite[],
ctx: RequestContext,
compiledPatterns?: Array<CompiledConfigPattern | null>,
): string | null {
for (const rewrite of rewrites) {
const params = matchConfigPattern(pathname, rewrite.source);
for (let i = 0; i < rewrites.length; i++) {
const rewrite = rewrites[i];
const compiled = compiledPatterns?.[i];
const params = compiled
? execCompiledConfigPattern(pathname, compiled)
: matchConfigPattern(pathname, rewrite.source);
if (params) {
const conditionParams =
rewrite.has || rewrite.missing
Expand Down Expand Up @@ -1124,16 +1162,23 @@ export function matchHeaders(
pathname: string,
headers: NextHeader[],
ctx: RequestContext,
compiledSources?: Array<RegExp | null>,
): Array<{ key: string; value: string }> {
const result: Array<{ key: string; value: string }> = [];
for (const rule of headers) {
// Cache the compiled source regex — escapeHeaderSource() + safeRegExp() are
// pure functions of rule.source and the result never changes between requests.
let sourceRegex = _compiledHeaderSourceCache.get(rule.source);
for (let i = 0; i < headers.length; i++) {
const rule = headers[i];
// Two-tier source lookup: the precompiled array (from buildPrecompiledConfigPatterns,
// passed by callers that hold a PrecompiledConfigPatterns object) is checked first.
// The module-level cache (_compiledHeaderSourceCache) is the fallback for callers
// that don't supply a precompiled array. escapeHeaderSource() + safeRegExp() are
// pure functions of rule.source so the compiled RegExp never changes between requests.
let sourceRegex = compiledSources?.[i];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle but correct: when compiledSources[i] is null (safeRegExp rejected the pattern at build time), null !== undefined so we correctly skip the runtime cache lookup and fall through to the if (sourceRegex && ...) check which is falsy for null. Good.

However, the comment above (line 1107-1108) is now stale — it describes the old single-tier cache but the code now has a two-tier lookup (precompiled array → module-level cache). Consider updating the comment to reflect the new flow.

if (sourceRegex === undefined) {
const escaped = escapeHeaderSource(rule.source);
sourceRegex = safeRegExp("^" + escaped + "$");
_compiledHeaderSourceCache.set(rule.source, sourceRegex);
sourceRegex = _compiledHeaderSourceCache.get(rule.source);
if (sourceRegex === undefined) {
sourceRegex = compileHeaderSourcePattern(rule.source);
_compiledHeaderSourceCache.set(rule.source, sourceRegex);
}
}
if (sourceRegex && sourceRegex.test(pathname)) {
if (rule.has || rule.missing) {
Expand Down
76 changes: 76 additions & 0 deletions packages/vinext/src/config/precompiled-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { NextHeader, NextRedirect, NextRewrite } from "./next-config.js";
import {
compileConfigPattern,
compileHeaderSourcePattern,
type CompiledConfigPattern,
} from "./config-matchers.js";

export type PrecompiledRewritePatterns = {
beforeFiles: Array<CompiledConfigPattern | null>;
afterFiles: Array<CompiledConfigPattern | null>;
fallback: Array<CompiledConfigPattern | null>;
};

export type PrecompiledConfigPatterns = {
redirects: Array<CompiledConfigPattern | null>;
rewrites: PrecompiledRewritePatterns;
headers: Array<RegExp | null>;
};

export function buildPrecompiledConfigPatterns(config: {
redirects?: NextRedirect[];
rewrites?: {
beforeFiles: NextRewrite[];
afterFiles: NextRewrite[];
fallback: NextRewrite[];
};
headers?: NextHeader[];
}): PrecompiledConfigPatterns {
const redirects = config.redirects ?? [];
const rewrites = config.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
const headers = config.headers ?? [];

return {
redirects: redirects.map((rule) => compileConfigPattern(rule.source)),
rewrites: {
beforeFiles: rewrites.beforeFiles.map((rule) => compileConfigPattern(rule.source)),
afterFiles: rewrites.afterFiles.map((rule) => compileConfigPattern(rule.source)),
fallback: rewrites.fallback.map((rule) => compileConfigPattern(rule.source)),
},
headers: headers.map((rule) => compileHeaderSourcePattern(rule.source)),
};
}

function serializeCompiledPattern(pattern: CompiledConfigPattern | null): string {
if (!pattern) return "null";
return `{ re: ${pattern.re.toString()}, paramNames: ${JSON.stringify(pattern.paramNames)} }`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor concern: RegExp.prototype.toString() produces /pattern/flags, which is valid JavaScript when embedded directly in generated code. However, if the regex pattern itself contains a forward slash (e.g., from an escaped path separator), toString() should still produce correct output since the slash is escaped in the regex source. This works correctly for all patterns I can see in the codebase.

That said, it would be good to have a roundtrip test that:

  1. Calls buildPrecompiledConfigCode with a non-trivial config
  2. Evaluates the generated code string (e.g., via new Function)
  3. Verifies the deserialized regex matches the same inputs as the original

This would catch any edge cases in the serialization format.

}

function serializeCompiledHeaderSource(pattern: RegExp | null): string {
return pattern ? pattern.toString() : "null";
}

export function buildPrecompiledConfigCode(config: {
redirects?: NextRedirect[];
rewrites?: {
beforeFiles: NextRewrite[];
afterFiles: NextRewrite[];
fallback: NextRewrite[];
};
headers?: NextHeader[];
}): {
redirects: string;
rewrites: string;
headers: string;
} {
const compiled = buildPrecompiledConfigPatterns(config);

return {
redirects: `[${compiled.redirects.map(serializeCompiledPattern).join(", ")}]`,
rewrites:
`{ beforeFiles: [${compiled.rewrites.beforeFiles.map(serializeCompiledPattern).join(", ")}], ` +
`afterFiles: [${compiled.rewrites.afterFiles.map(serializeCompiledPattern).join(", ")}], ` +
`fallback: [${compiled.rewrites.fallback.map(serializeCompiledPattern).join(", ")}] }`,
headers: `[${compiled.headers.map(serializeCompiledHeaderSource).join(", ")}]`,
};
}
Loading
Loading