From 711e283f9676d5c8f3448e8f2b83fd0a3edba8b2 Mon Sep 17 00:00:00 2001 From: SeolJaeHyeok Date: Sat, 14 Mar 2026 17:12:54 +0900 Subject: [PATCH 1/5] refactor: extract reusable config pattern compilers --- packages/vinext/src/config/config-matchers.ts | 179 +++++++++++------- .../vinext/src/config/precompiled-config.ts | 76 ++++++++ tests/shims.test.ts | 31 +++ 3 files changed, 222 insertions(+), 64 deletions(-) create mode 100644 packages/vinext/src/config/precompiled-config.ts diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index f2e23b737..a60cdc566 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -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(); +const _compiledPatternCache = new Map(); /** * Cache for compiled header source regexes in matchHeaders. @@ -583,6 +588,75 @@ 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 | null { + const match = compiled.re.exec(pathname); + if (!match) return null; + const params: Record = Object.create(null); + for (let i = 0; i < compiled.paramNames.length; i++) { + params[compiled.paramNames[i]] = match[i + 1] ?? ""; + } + return params; +} + +export function compileConfigPattern(pattern: string): CompiledConfigPattern | null { + if (!usesRegexBranch(pattern)) return null; + + 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. @@ -606,68 +680,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 = 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 } @@ -750,9 +771,30 @@ export function matchRedirect( pathname: string, redirects: NextRedirect[], ctx: RequestContext, + compiledPatterns?: Array, ): { destination: string; permanent: boolean } | null { if (redirects.length === 0) return null; + if (compiledPatterns) { + for (let i = 0; i < redirects.length; i++) { + const redirect = redirects[i]; + const compiled = compiledPatterns[i]; + const params = compiled + ? execCompiledConfigPattern(pathname, compiled) + : matchConfigPattern(pathname, redirect.source); + if (!params) continue; + if (redirect.has || redirect.missing) { + if (!checkHasConditions(redirect.has, redirect.missing, ctx)) { + continue; + } + } + let dest = substituteDestinationParams(redirect.destination, params); + dest = sanitizeDestination(dest); + return { destination: dest, permanent: redirect.permanent }; + } + return null; + } + const index = _getRedirectIndex(redirects); // --- Locate the best locale-static candidate --- @@ -861,9 +903,14 @@ export function matchRewrite( pathname: string, rewrites: NextRewrite[], ctx: RequestContext, + compiledPatterns?: Array, ): 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) { if (rewrite.has || rewrite.missing) { if (!checkHasConditions(rewrite.has, rewrite.missing, ctx)) { @@ -1052,16 +1099,20 @@ export function matchHeaders( pathname: string, headers: NextHeader[], ctx: RequestContext, + compiledSources?: Array, ): Array<{ key: string; value: string }> { const result: Array<{ key: string; value: string }> = []; - for (const rule of headers) { + for (let i = 0; i < headers.length; i++) { + const rule = headers[i]; // 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); + let sourceRegex = compiledSources?.[i]; 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) { diff --git a/packages/vinext/src/config/precompiled-config.ts b/packages/vinext/src/config/precompiled-config.ts new file mode 100644 index 000000000..9843fdefa --- /dev/null +++ b/packages/vinext/src/config/precompiled-config.ts @@ -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; + afterFiles: Array; + fallback: Array; +}; + +export type PrecompiledConfigPatterns = { + redirects: Array; + rewrites: PrecompiledRewritePatterns; + headers: Array; +}; + +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)} }`; +} + +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(", ")}]`, + }; +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 24a5bfb71..82a2a8e3c 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4411,6 +4411,37 @@ describe("matchConfigPattern compiled pattern cache", () => { }); }); +describe("compileConfigPattern", () => { + it("extracts a reusable compiled regex and param names for regex-branch patterns", async () => { + const { compileConfigPattern } = await import( + "../packages/vinext/src/config/config-matchers.js" + ); + + const compiled = compileConfigPattern( + "/:locale(en|es|fr|id|ja|ko|pt-br|pt|ro|ta|tr|uk|zh-cn|zh-tw)?/security", + ); + + expect(compiled).not.toBeNull(); + expect(compiled?.paramNames).toEqual(["locale"]); + expect(compiled?.re).toBeInstanceOf(RegExp); + expect(compiled?.re.exec("/en/security")?.[1]).toBe("en"); + }); +}); + +describe("compileHeaderSourcePattern", () => { + it("compiles header source patterns for build-time reuse", async () => { + const { compileHeaderSourcePattern } = await import( + "../packages/vinext/src/config/config-matchers.js" + ); + + const compiled = compileHeaderSourcePattern("/api/:path*"); + + expect(compiled).toBeInstanceOf(RegExp); + expect(compiled?.test("/api/users")).toBe(true); + expect(compiled?.test("/about")).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // matchRedirect locale-static index tests // Verifies the O(1) locale-prefix optimization in matchRedirect. From 893bf15239d9c760db8f35b4194277b427bcd79a Mon Sep 17 00:00:00 2001 From: SeolJaeHyeok Date: Sat, 14 Mar 2026 17:15:06 +0900 Subject: [PATCH 2/5] feat: emit precompiled config matchers in generated entries --- packages/vinext/src/entries/app-rsc-entry.ts | 25 ++- .../vinext/src/entries/pages-server-entry.ts | 11 ++ .../entry-templates.test.ts.snap | 143 ++++++++++++++---- tests/app-router.test.ts | 7 +- tests/entry-templates.test.ts | 31 ++++ 5 files changed, 181 insertions(+), 36 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a594972e2..845ada46e 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -15,6 +15,7 @@ import type { NextRedirect, NextRewrite, } from "../config/next-config.js"; +import { buildPrecompiledConfigCode } from "../config/precompiled-config.js"; import type { AppRoute } from "../routing/app-router.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; import type { MetadataFileRoute } from "../server/metadata-routes.js"; @@ -112,6 +113,7 @@ export function generateRscEntry( const redirects = config?.redirects ?? []; const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; const headers = config?.headers ?? []; + const compiledConfig = buildPrecompiledConfigCode({ redirects, rewrites, headers }); const allowedOrigins = config?.allowedOrigins ?? []; const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; const i18nConfig = config?.i18n ?? null; @@ -1332,6 +1334,9 @@ const __i18nConfig = ${JSON.stringify(i18nConfig)}; const __configRedirects = ${JSON.stringify(redirects)}; const __configRewrites = ${JSON.stringify(rewrites)}; const __configHeaders = ${JSON.stringify(headers)}; +const __compiledRedirects = ${compiledConfig.redirects}; +const __compiledRewrites = ${compiledConfig.rewrites}; +const __compiledHeaders = ${compiledConfig.headers}; const __allowedOrigins = ${JSON.stringify(allowedOrigins)}; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1472,7 +1477,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""} - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -1542,7 +1547,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -1669,7 +1674,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -1939,7 +1944,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -1954,7 +1964,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 0736a2a14..f749fbbde 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { pagesRouter, apiRouter, type Route } from "../routing/pages-router.js"; import { createValidFileMatcher } from "../routing/file-matcher.js"; import { type ResolvedNextConfig } from "../config/next-config.js"; +import { buildPrecompiledConfigCode } from "../config/precompiled-config.js"; import { isProxyFile } from "../server/middleware.js"; import { generateSafeRegExpCode, @@ -114,6 +115,11 @@ export async function generateServerEntry( contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy, }, }); + const precompiledConfigCode = buildPrecompiledConfigCode({ + redirects: nextConfig?.redirects ?? [], + rewrites: nextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: nextConfig?.headers ?? [], + }); // Generate instrumentation code if instrumentation.ts exists. // For production (Cloudflare Workers), instrumentation.ts is bundled into the @@ -293,6 +299,11 @@ const buildId = ${buildIdJson}; // Full resolved config for production server (embedded at build time) export const vinextConfig = ${vinextConfigJson}; +export const vinextCompiledConfig = { + redirects: ${precompiledConfigCode.redirects}, + rewrites: ${precompiledConfigCode.rewrites}, + headers: ${precompiledConfigCode.headers}, +}; class ApiBodyParseError extends Error { constructor(message, statusCode) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8682a2cb1..76cb9e7a9 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1410,6 +1410,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -1705,7 +1708,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -1768,7 +1771,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -1803,7 +1806,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -2073,7 +2076,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -2088,7 +2096,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); @@ -4186,6 +4199,9 @@ const __i18nConfig = null; const __configRedirects = [{"source":"/old","destination":"/new","permanent":true}]; const __configRewrites = {"beforeFiles":[{"source":"/api/:path*","destination":"/backend/:path*"}],"afterFiles":[],"fallback":[]}; const __configHeaders = [{"source":"/api/:path*","headers":[{"key":"X-Custom","value":"test"}]}]; +const __compiledRedirects = [null]; +const __compiledRewrites = { beforeFiles: [null], afterFiles: [], fallback: [] }; +const __compiledHeaders = [/^\\/api\\/[^/]+.*$/]; const __allowedOrigins = ["https://example.com"]; @@ -4481,7 +4497,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -4547,7 +4563,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -4582,7 +4598,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -4852,7 +4868,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -4867,7 +4888,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); @@ -6995,6 +7021,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -7290,7 +7319,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -7353,7 +7382,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -7388,7 +7417,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -7658,7 +7687,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -7673,7 +7707,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); @@ -9808,6 +9847,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -10106,7 +10148,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -10169,7 +10211,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -10204,7 +10246,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -10474,7 +10516,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -10489,7 +10536,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); @@ -12594,6 +12646,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -12889,7 +12944,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -12952,7 +13007,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -12987,7 +13042,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -13257,7 +13312,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -13272,7 +13332,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); @@ -15599,6 +15664,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -15894,7 +15962,7 @@ export default async function handler(request, ctx) { let pathname; try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; } - const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); + const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx, __compiledHeaders); for (const h of extraHeaders) { // Use append() for headers where multiple values must coexist // (Vary, Set-Cookie). Using set() on these would destroy @@ -15957,7 +16025,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // arrive as /some/path.rsc but redirect patterns are defined without it (e.g. // /some/path). Without this, soft-nav fetches bypass all config redirects. const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname; - const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx); + const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects); if (__redir) { const __redirDest = sanitizeDestination( __basePath && @@ -16080,7 +16148,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // In App Router execution order, beforeFiles runs after middleware so that // has/missing conditions can evaluate against middleware-modified headers. if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) { - const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx); + const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx, __compiledRewrites.beforeFiles); if (__rewritten) { if (isExternalUrl(__rewritten)) { setHeadersContext(null); @@ -16350,7 +16418,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Apply afterFiles rewrites from next.config.js ────────────────────── if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) { - const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx); + const __afterRewritten = matchRewrite( + cleanPathname, + __configRewrites.afterFiles, + __postMwReqCtx, + __compiledRewrites.afterFiles, + ); if (__afterRewritten) { if (isExternalUrl(__afterRewritten)) { setHeadersContext(null); @@ -16365,7 +16438,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // ── Fallback rewrites from next.config.js (if no route matched) ─────── if (!match && __configRewrites.fallback && __configRewrites.fallback.length) { - const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx); + const __fallbackRewritten = matchRewrite( + cleanPathname, + __configRewrites.fallback, + __postMwReqCtx, + __compiledRewrites.fallback, + ); if (__fallbackRewritten) { if (isExternalUrl(__fallbackRewritten)) { setHeadersContext(null); @@ -17974,6 +18052,11 @@ const buildId = "test-build-id"; // Full resolved config for production server (embedded at build time) export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false},{"source":"/redirect-before-middleware-rewrite","destination":"/about","permanent":false},{"source":"/redirect-before-middleware-response","destination":"/about","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]},{"source":"/headers-before-middleware-rewrite","headers":[{"key":"X-Rewrite-Source-Header","value":"1"}]}],"i18n":null,"images":{}}; +export const vinextCompiledConfig = { + redirects: [null, null, null, null], + rewrites: { beforeFiles: [null, null, null], afterFiles: [null, null], fallback: [null] }, + headers: [/^\\/api\\/(.*)$/, /^\\/about$/, /^\\/about$/, /^\\/ssr$/, /^\\/headers-before-middleware-rewrite$/], +}; class ApiBodyParseError extends Error { constructor(message, statusCode) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eead11c3a..1cf60d626 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2400,6 +2400,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { ], }); expect(code).toContain("__configRedirects"); + expect(code).toContain("__compiledRedirects"); expect(code).toContain("matchRedirect"); expect(code).toContain("/old-about"); expect(code).toContain("/old-blog/:slug"); @@ -2415,6 +2416,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { }, }); expect(code).toContain("__configRewrites"); + expect(code).toContain("__compiledRewrites"); expect(code).toContain("matchRewrite"); expect(code).toContain("beforeFiles"); expect(code).toContain("afterFiles"); @@ -2429,6 +2431,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { headers: [{ source: "/api/(.*)", headers: [{ key: "X-Custom-Header", value: "vinext" }] }], }); expect(code).toContain("__configHeaders"); + expect(code).toContain("__compiledHeaders"); expect(code).toContain("matchHeaders"); expect(code).toContain("X-Custom-Header"); expect(code).toContain("vinext"); @@ -2499,7 +2502,9 @@ describe("App Router next.config.js features (generateRscEntry)", () => { redirects: [{ source: "/old", destination: "/new", permanent: true }], }); // The redirect check should appear before middleware and route matching - const redirectIdx = code.indexOf("matchRedirect(__redirPathname"); + const redirectIdx = code.indexOf( + "matchRedirect(__redirPathname, __configRedirects, __reqCtx, __compiledRedirects)", + ); const routeMatchIdx = code.indexOf("matchRoute(cleanPathname"); expect(redirectIdx).toBeGreaterThan(-1); expect(routeMatchIdx).toBeGreaterThan(-1); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index cc15c98ec..a82cbe099 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -16,7 +16,9 @@ import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.j import type { AppRouterConfig } from "../packages/vinext/src/entries/app-rsc-entry.js"; import { generateSsrEntry } from "../packages/vinext/src/entries/app-ssr-entry.js"; import { generateBrowserEntry } from "../packages/vinext/src/entries/app-browser-entry.js"; +import { generateServerEntry as generatePagesServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js"; import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; +import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js"; import vinext from "../packages/vinext/src/index.js"; @@ -292,6 +294,35 @@ describe("Pages Router entry templates", () => { expect(waitUntilCall).toBeGreaterThan(renderFnCall); }); + it("direct pages server entry generation exports precompiled config matchers", async () => { + const code = await generatePagesServerEntry( + PAGES_FIXTURE_DIR, + { + pageExtensions: ["tsx", "ts", "jsx", "js"], + basePath: "", + trailingSlash: false, + redirects: [{ source: "/old/:slug", destination: "/new/:slug", permanent: false }], + rewrites: { + beforeFiles: [{ source: "/before/:path*", destination: "/after/:path*" }], + afterFiles: [], + fallback: [], + }, + headers: [{ source: "/api/:path*", headers: [{ key: "X-Test", value: "1" }] }], + i18n: null, + images: {}, + } as any, + createValidFileMatcher(["tsx", "ts", "jsx", "js"]), + null, + null, + ); + + expect(code).toContain("export const vinextCompiledConfig = {"); + expect(code).toContain("redirects:"); + expect(code).toContain("rewrites:"); + expect(code).toContain("headers:"); + expect(code).toContain("/^"); + }); + it("server entry seeds the main Pages Router unified context with executionContext", async () => { const code = await getVirtualModuleCode("virtual:vinext-server-entry"); const renderPageIndex = code.indexOf("async function _renderPage(request, url, manifest) {"); From d74e26610d1aebe38b6f21890d13ec5f4469764c Mon Sep 17 00:00:00 2001 From: SeolJaeHyeok Date: Sat, 14 Mar 2026 17:15:15 +0900 Subject: [PATCH 3/5] feat: use precompiled config matchers in request handlers --- packages/vinext/src/deploy.ts | 36 +++++++++++++++++---- packages/vinext/src/index.ts | 38 ++++++++++++++++++++--- packages/vinext/src/server/prod-server.ts | 25 ++++++++++++--- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index abd6984cc..fa936e596 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -510,7 +510,13 @@ import { } from "vinext/config/config-matchers"; // @ts-expect-error -- virtual module resolved by vinext at build time -import { renderPage, handleApiRoute, runMiddleware, vinextConfig } from "virtual:vinext-server-entry"; +import { + renderPage, + handleApiRoute, + runMiddleware, + vinextConfig, + vinextCompiledConfig, +} from "virtual:vinext-server-entry"; interface Env { ASSETS: Fetcher; @@ -534,6 +540,9 @@ const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false; const configRedirects = vinextConfig?.redirects ?? []; const configRewrites = vinextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] }; const configHeaders = vinextConfig?.headers ?? []; +const compiledRedirects = vinextCompiledConfig?.redirects; +const compiledRewrites = vinextCompiledConfig?.rewrites; +const compiledHeaders = vinextCompiledConfig?.headers; const imageConfig: ImageConfig | undefined = vinextConfig?.images ? { dangerouslyAllowSVG: vinextConfig.images.dangerouslyAllowSVG, contentDispositionType: vinextConfig.images.contentDispositionType, @@ -623,7 +632,7 @@ export default { // ── 3. Apply redirects from next.config.js ──────────────────── if (configRedirects.length) { - const redirect = matchRedirect(pathname, configRedirects, reqCtx); + const redirect = matchRedirect(pathname, configRedirects, reqCtx, compiledRedirects); if (redirect) { const dest = sanitizeDestination( basePath && @@ -711,7 +720,7 @@ export default { // Middleware headers take precedence: skip config keys already set // by middleware so middleware always wins for the same key. if (configHeaders.length) { - const matched = matchHeaders(pathname, configHeaders, reqCtx); + const matched = matchHeaders(pathname, configHeaders, reqCtx, compiledHeaders); for (const h of matched) { const lk = h.key.toLowerCase(); if (lk === "set-cookie") { @@ -735,7 +744,12 @@ export default { // ��─ 6. Apply beforeFiles rewrites from next.config.js ───────── if (configRewrites.beforeFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx); + const rewritten = matchRewrite( + resolvedPathname, + configRewrites.beforeFiles, + postMwReqCtx, + compiledRewrites?.beforeFiles, + ); if (rewritten) { if (isExternalUrl(rewritten)) { return proxyExternalRequest(request, rewritten); @@ -755,7 +769,12 @@ export default { // ── 8. Apply afterFiles rewrites from next.config.js ────────── if (configRewrites.afterFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx); + const rewritten = matchRewrite( + resolvedPathname, + configRewrites.afterFiles, + postMwReqCtx, + compiledRewrites?.afterFiles, + ); if (rewritten) { if (isExternalUrl(rewritten)) { return proxyExternalRequest(request, rewritten); @@ -772,7 +791,12 @@ export default { // ── 10. Fallback rewrites (if SSR returned 404) ───────────── if (response && response.status === 404 && configRewrites.fallback?.length) { - const fallbackRewrite = matchRewrite(resolvedPathname, configRewrites.fallback, postMwReqCtx); + const fallbackRewrite = matchRewrite( + resolvedPathname, + configRewrites.fallback, + postMwReqCtx, + compiledRewrites?.fallback, + ); if (fallbackRewrite) { if (isExternalUrl(fallbackRewrite)) { return proxyExternalRequest(request, fallbackRewrite); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 50da4b418..dfaffb390 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -41,7 +41,12 @@ import { requestContextFromRequest, sanitizeDestination, type RequestContext, + type CompiledConfigPattern, } from "./config/config-matchers.js"; +import { + buildPrecompiledConfigPatterns, + type PrecompiledConfigPatterns, +} from "./config/precompiled-config.js"; import { scanMetadataFiles } from "./server/metadata-routes.js"; import { staticExportPages } from "./build/static-export.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; @@ -727,6 +732,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Shim alias map — populated in config(), used by resolveId() for .js variants let nextShimMap: Record = {}; + let precompiledNextConfig: PrecompiledConfigPatterns = { + redirects: [], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: [], + }; // Build-only cache for og-inline-fetch-assets to avoid repeated file reads // during a single production build. Dev mode skips the cache so asset edits @@ -937,6 +947,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const phase = env?.command === "build" ? PHASE_PRODUCTION_BUILD : PHASE_DEVELOPMENT_SERVER; const rawConfig = await loadNextConfig(root, phase); nextConfig = await resolveNextConfig(rawConfig, root); + precompiledNextConfig = buildPrecompiledConfigPatterns(nextConfig); fileMatcher = createValidFileMatcher(nextConfig.pageExtensions); // Merge env from next.config.js with NEXT_PUBLIC_* env vars @@ -2184,6 +2195,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { nextConfig.redirects, preMiddlewareReqCtx, nextConfig.basePath ?? "", + precompiledNextConfig.redirects, ); if (redirected) return; } @@ -2318,14 +2330,25 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // pre-middleware request state; middleware response headers win // later because they are already on the outgoing response. if (nextConfig?.headers.length) { - applyHeaders(pathname, res, nextConfig.headers, preMiddlewareReqCtx); + applyHeaders( + pathname, + res, + nextConfig.headers, + preMiddlewareReqCtx, + precompiledNextConfig.headers, + ); } // Apply rewrites from next.config.js (beforeFiles) let resolvedUrl = url; if (nextConfig?.rewrites.beforeFiles.length) { resolvedUrl = - applyRewrites(pathname, nextConfig.rewrites.beforeFiles, reqCtx) ?? url; + applyRewrites( + pathname, + nextConfig.rewrites.beforeFiles, + reqCtx, + precompiledNextConfig.rewrites.beforeFiles, + ) ?? url; } // External rewrite from beforeFiles — proxy to external URL @@ -2369,6 +2392,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { resolvedUrl.split("?")[0], nextConfig.rewrites.afterFiles, reqCtx, + precompiledNextConfig.rewrites.afterFiles, ); if (afterRewrite) resolvedUrl = afterRewrite; } @@ -2406,6 +2430,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { resolvedUrl.split("?")[0], nextConfig.rewrites.fallback, reqCtx, + precompiledNextConfig.rewrites.fallback, ); if (fallbackRewrite) { // External fallback rewrite — proxy to external URL @@ -3544,8 +3569,9 @@ function applyRedirects( redirects: NextRedirect[], ctx: RequestContext, basePath = "", + compiledPatterns?: Array, ): boolean { - const result = matchRedirect(pathname, redirects, ctx); + const result = matchRedirect(pathname, redirects, ctx, compiledPatterns); if (result) { // Sanitize to prevent open redirect via protocol-relative URLs const dest = sanitizeDestination( @@ -3631,8 +3657,9 @@ function applyRewrites( pathname: string, rewrites: NextRewrite[], ctx: RequestContext, + compiledPatterns?: Array, ): string | null { - const dest = matchRewrite(pathname, rewrites, ctx); + const dest = matchRewrite(pathname, rewrites, ctx, compiledPatterns); if (dest) { // Sanitize to prevent open redirect via protocol-relative URLs return sanitizeDestination(dest); @@ -3650,8 +3677,9 @@ function applyHeaders( res: any, headers: NextHeader[], ctx: RequestContext, + compiledSources?: Array, ): void { - const matched = matchHeaders(pathname, headers, ctx); + const matched = matchHeaders(pathname, headers, ctx, compiledSources); for (const header of matched) { // Use append semantics for headers where multiple values must coexist // (Vary, Set-Cookie). Using setHeader() on these would destroy diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 951ec231b..7c3a5e8fb 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -728,7 +728,8 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Import the server entry module (use file:// URL for reliable dynamic import) const serverEntry = await import(pathToFileURL(serverEntryPath).href); - const { renderPage, handleApiRoute: handleApi, runMiddleware, vinextConfig } = serverEntry; + const { renderPage, handleApiRoute: handleApi, runMiddleware, vinextConfig, vinextCompiledConfig } = + serverEntry; // Extract config values (embedded at build time in the server entry) const basePath: string = vinextConfig?.basePath ?? ""; @@ -741,6 +742,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { fallback: [], }; const configHeaders = vinextConfig?.headers ?? []; + const compiledRedirects = vinextCompiledConfig?.redirects; + const compiledRewrites = vinextCompiledConfig?.rewrites; + const compiledHeaders = vinextCompiledConfig?.headers; // Compute allowed image widths from config (union of deviceSizes + imageSizes) const allowedImageWidths: number[] = [ ...(vinextConfig?.images?.deviceSizes ?? DEFAULT_DEVICE_SIZES), @@ -910,7 +914,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // ── 4. Apply redirects from next.config.js ──────────────────── if (configRedirects.length) { - const redirect = matchRedirect(pathname, configRedirects, reqCtx); + const redirect = matchRedirect(pathname, configRedirects, reqCtx, compiledRedirects); if (redirect) { // Guard against double-prefixing: only add basePath if destination // doesn't already start with it. @@ -1028,7 +1032,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Middleware headers take precedence: skip config keys already set // by middleware so middleware always wins for the same key. if (configHeaders.length) { - const matched = matchHeaders(pathname, configHeaders, reqCtx); + const matched = matchHeaders(pathname, configHeaders, reqCtx, compiledHeaders); for (const h of matched) { const lk = h.key.toLowerCase(); if (lk === "set-cookie") { @@ -1052,7 +1056,12 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // ── 7. Apply beforeFiles rewrites from next.config.js ───────── if (configRewrites.beforeFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.beforeFiles, postMwReqCtx); + const rewritten = matchRewrite( + resolvedPathname, + configRewrites.beforeFiles, + postMwReqCtx, + compiledRewrites?.beforeFiles, + ); if (rewritten) { if (isExternalUrl(rewritten)) { const proxyResponse = await proxyExternalRequest(webRequest, rewritten); @@ -1099,7 +1108,12 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // ── 9. Apply afterFiles rewrites from next.config.js ────────── if (configRewrites.afterFiles?.length) { - const rewritten = matchRewrite(resolvedPathname, configRewrites.afterFiles, postMwReqCtx); + const rewritten = matchRewrite( + resolvedPathname, + configRewrites.afterFiles, + postMwReqCtx, + compiledRewrites?.afterFiles, + ); if (rewritten) { if (isExternalUrl(rewritten)) { const proxyResponse = await proxyExternalRequest(webRequest, rewritten); @@ -1122,6 +1136,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { resolvedPathname, configRewrites.fallback, postMwReqCtx, + compiledRewrites?.fallback, ); if (fallbackRewrite) { if (isExternalUrl(fallbackRewrite)) { From 37125bd65e05bd6e504441d9465c73ce473085c0 Mon Sep 17 00:00:00 2001 From: SeolJaeHyeok Date: Wed, 18 Mar 2026 20:34:17 +0900 Subject: [PATCH 4/5] fix: address precompiled config code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Blocking] matchRedirect with compiledPatterns bypassed the locale-static index entirely — the if (compiledPatterns) early-return ran a plain O(n×regex) linear scan, reintroducing the ~2992ms self-time the index was built to eliminate on apps with 63+ locale-prefixed rules. Remove the independent early-return block and absorb compiledPatterns into the existing linear fallback loop via compiledPatterns?.[origIdx], so the locale-static O(1) map-lookup runs unconditionally regardless of whether compiledPatterns is supplied. [Non-blocking] Add tests/precompiled-config.test.ts with serialization roundtrip tests that evaluate the output of buildPrecompiledConfigCode via new Function() and assert the resulting RegExp objects match and reject paths correctly. Covers inline regex groups, named params with alternation constraints, hyphenated param names, and simple patterns that compile to null. [Non-blocking] serializeCompiledPattern uses JSON.stringify for paramNames, which correctly handles any special characters — no code change needed. [Non-blocking] Update the matchHeaders comment to reflect the two-tier source lookup order: precompiled array first, module-level cache as fallback. --- packages/vinext/src/config/config-matchers.ts | 32 +--- tests/precompiled-config.test.ts | 165 ++++++++++++++++++ 2 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 tests/precompiled-config.test.ts diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index a60cdc566..7e81b1301 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -775,26 +775,6 @@ export function matchRedirect( ): { destination: string; permanent: boolean } | null { if (redirects.length === 0) return null; - if (compiledPatterns) { - for (let i = 0; i < redirects.length; i++) { - const redirect = redirects[i]; - const compiled = compiledPatterns[i]; - const params = compiled - ? execCompiledConfigPattern(pathname, compiled) - : matchConfigPattern(pathname, redirect.source); - if (!params) continue; - if (redirect.has || redirect.missing) { - if (!checkHasConditions(redirect.has, redirect.missing, ctx)) { - continue; - } - } - let dest = substituteDestinationParams(redirect.destination, params); - dest = sanitizeDestination(dest); - return { destination: dest, permanent: redirect.permanent }; - } - return null; - } - const index = _getRedirectIndex(redirects); // --- Locate the best locale-static candidate --- @@ -873,7 +853,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) { if (redirect.has || redirect.missing) { if (!checkHasConditions(redirect.has, redirect.missing, ctx)) { @@ -1104,8 +1087,11 @@ export function matchHeaders( const result: Array<{ key: string; value: string }> = []; for (let i = 0; i < headers.length; i++) { const rule = headers[i]; - // Cache the compiled source regex — escapeHeaderSource() + safeRegExp() are - // pure functions of rule.source and the result never changes between requests. + // 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]; if (sourceRegex === undefined) { sourceRegex = _compiledHeaderSourceCache.get(rule.source); diff --git a/tests/precompiled-config.test.ts b/tests/precompiled-config.test.ts new file mode 100644 index 000000000..f7217fb7b --- /dev/null +++ b/tests/precompiled-config.test.ts @@ -0,0 +1,165 @@ +/** + * Serialization roundtrip tests for buildPrecompiledConfigCode. + * + * These tests verify that compiled regex patterns survive the full + * RegExp.toString() → code-embed → eval cycle correctly. A pattern that + * looks right in a snapshot can still silently break if regex flags are + * dropped, forward slashes are mis-escaped, or special characters in param + * names corrupt the serialized JSON. + */ +import { describe, it, expect } from "vitest"; +import { buildPrecompiledConfigCode } from "../packages/vinext/src/config/precompiled-config.js"; + +type CompiledPattern = { re: RegExp; paramNames: string[] } | null; +type CompiledRewrites = { + beforeFiles: CompiledPattern[]; + afterFiles: CompiledPattern[]; + fallback: CompiledPattern[]; +}; + +/** Evaluate a serialized array/object expression and return the result. */ +function evalCode(code: string): T { + // eslint-disable-next-line no-new-func + return new Function(`return ${code}`)() as T; +} + +describe("buildPrecompiledConfigCode serialization roundtrip", () => { + it("redirect patterns with inline regex groups survive roundtrip", () => { + const config = { + redirects: [ + // Inline regex group — usesRegexBranch → true, compiles to non-null + { source: "/items/(\\d+)/detail", destination: "/item-detail", permanent: false }, + // Named param with alternation constraint + { source: "/:lang(en|fr)/about", destination: "/about", permanent: true }, + // Simple static path — no regex branch, compiles to null + { source: "/old-path", destination: "/new-path", permanent: true }, + ], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: [], + }; + + const code = buildPrecompiledConfigCode(config); + const compiled = evalCode(code.redirects); + + // Pattern 0: inline \d+ group + expect(compiled[0]).not.toBeNull(); + expect(compiled[0]!.re.test("/items/42/detail")).toBe(true); + expect(compiled[0]!.re.test("/items/abc/detail")).toBe(false); + expect(compiled[0]!.re.test("/items/42")).toBe(false); + + // Pattern 1: named param with alternation constraint — paramNames preserved + expect(compiled[1]).not.toBeNull(); + expect(compiled[1]!.re.test("/en/about")).toBe(true); + expect(compiled[1]!.re.test("/fr/about")).toBe(true); + expect(compiled[1]!.re.test("/de/about")).toBe(false); + expect(compiled[1]!.paramNames).toEqual(["lang"]); + + // Pattern 2: simple static path — no regex branch, must be null + expect(compiled[2]).toBeNull(); + }); + + it("rewrite patterns survive roundtrip", () => { + const config = { + redirects: [], + rewrites: { + beforeFiles: [ + // Param with dot suffix — usesRegexBranch via :[\w-]+\. check + { source: "/:slug.html", destination: "/:slug" }, + ], + afterFiles: [ + // Named param with constraint + { source: "/:version(v1|v2)/api/:path*", destination: "/api/:path*" }, + ], + fallback: [ + // Simple param — no regex branch, null + { source: "/:page", destination: "/page/:page" }, + ], + }, + headers: [], + }; + + const code = buildPrecompiledConfigCode(config); + const compiled = evalCode(code.rewrites); + + // beforeFiles[0]: /:slug.html + expect(compiled.beforeFiles[0]).not.toBeNull(); + expect(compiled.beforeFiles[0]!.re.test("/my-post.html")).toBe(true); + expect(compiled.beforeFiles[0]!.re.test("/my-post.htm")).toBe(false); + expect(compiled.beforeFiles[0]!.paramNames).toEqual(["slug"]); + + // afterFiles[0]: /:version(v1|v2)/api/:path* + expect(compiled.afterFiles[0]).not.toBeNull(); + expect(compiled.afterFiles[0]!.re.test("/v1/api/users")).toBe(true); + expect(compiled.afterFiles[0]!.re.test("/v2/api/posts/1")).toBe(true); + expect(compiled.afterFiles[0]!.re.test("/v3/api/users")).toBe(false); + + // fallback[0]: /:page — simple param, null + expect(compiled.fallback[0]).toBeNull(); + }); + + it("header source patterns survive roundtrip", () => { + const config = { + redirects: [], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: [ + { source: "/api/(.*)", headers: [{ key: "X-Api", value: "1" }] }, + { source: "/static/path", headers: [{ key: "X-Static", value: "1" }] }, + ], + }; + + const code = buildPrecompiledConfigCode(config); + const compiled = evalCode>(code.headers); + + // Header 0: /api/(.*) + expect(compiled[0]).not.toBeNull(); + expect(compiled[0]! instanceof RegExp).toBe(true); + expect(compiled[0]!.test("/api/users")).toBe(true); + expect(compiled[0]!.test("/api/nested/path")).toBe(true); + expect(compiled[0]!.test("/other/path")).toBe(false); + + // Header 1: /static/path — static source also compiles to a regex + expect(compiled[1]).not.toBeNull(); + expect(compiled[1]!.test("/static/path")).toBe(true); + expect(compiled[1]!.test("/static/other")).toBe(false); + }); + + it("empty config produces empty arrays without throwing", () => { + const code = buildPrecompiledConfigCode({ + redirects: [], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: [], + }); + + expect(() => evalCode(code.redirects)).not.toThrow(); + expect(() => evalCode(code.rewrites)).not.toThrow(); + expect(() => evalCode(code.headers)).not.toThrow(); + + expect(evalCode(code.redirects)).toEqual([]); + expect(evalCode(code.rewrites)).toEqual({ + beforeFiles: [], + afterFiles: [], + fallback: [], + }); + expect(evalCode>(code.headers)).toEqual([]); + }); + + it("hyphenated param names are preserved through JSON.stringify serialization", () => { + const config = { + redirects: [ + // Hyphenated param name — valid in Next.js patterns + { source: "/:auth-token(Bearer|Basic)", destination: "/auth", permanent: false }, + ], + rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, + headers: [], + }; + + const code = buildPrecompiledConfigCode(config); + const compiled = evalCode(code.redirects); + + expect(compiled[0]).not.toBeNull(); + expect(compiled[0]!.paramNames).toEqual(["auth-token"]); + expect(compiled[0]!.re.test("/Bearer")).toBe(true); + expect(compiled[0]!.re.test("/Basic")).toBe(true); + expect(compiled[0]!.re.test("/Digest")).toBe(false); + }); +}); From d0577880c11ac33b374ed134775fb0fa75b12486 Mon Sep 17 00:00:00 2001 From: milkboy2564 <71430291+SeolJaeHyeok@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:03:50 +0900 Subject: [PATCH 5/5] docs: clarify compileConfigPattern null return semantics Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> --- packages/vinext/src/config/config-matchers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index 7e81b1301..7c08c3b35 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -610,6 +610,14 @@ function execCompiledConfigPattern( 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;