-
Notifications
You must be signed in to change notification settings - Fork 253
feat: precompile next.config matchers at build time #536
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
base: main
Are you sure you want to change the base?
Changes from all commits
711e283
893bf15
d74e266
37125bd
d057788
3fa8cdf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| * | ||
|
|
@@ -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. | ||
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting: 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 |
||
|
|
||
| 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. | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Subtle but correct: when 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) { | ||
|
|
||
| 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)} }`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor concern: That said, it would be good to have a roundtrip test that:
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(", ")}]`, | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.