diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index 87b804916..062f08e48 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. @@ -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 | 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; +} + +/** + * 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; + + 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 = 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, ): { 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, ): 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, ): 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]; 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/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index cdf4462f7..c02a8fb29 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -524,7 +524,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; @@ -548,6 +554,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, @@ -637,7 +646,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 && @@ -725,7 +734,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") { @@ -749,7 +758,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); @@ -769,7 +783,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); @@ -786,7 +805,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/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 93070ba3b..71c9a344e 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"; @@ -121,6 +122,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; @@ -1387,6 +1389,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)} @@ -1545,7 +1550,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 @@ -1688,7 +1693,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 && @@ -1858,7 +1863,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); @@ -2128,7 +2133,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); @@ -2143,7 +2153,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 de96eb2db..a1474ff78 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 @@ -294,6 +300,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/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fd78d5776..ac6ba7f18 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 { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { detectPackageManager } from "./utils/project.js"; @@ -852,6 +857,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 @@ -1502,6 +1512,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 @@ -2778,6 +2789,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { nextConfig.redirects, preMiddlewareReqCtx, nextConfig.basePath ?? "", + precompiledNextConfig.redirects, ); if (redirected) return; } @@ -2955,14 +2967,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 @@ -3016,6 +3039,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { resolvedUrl.split("?")[0], nextConfig.rewrites.afterFiles, reqCtx, + precompiledNextConfig.rewrites.afterFiles, ); if (afterRewrite) resolvedUrl = afterRewrite; } @@ -3056,6 +3080,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 @@ -4295,8 +4320,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( @@ -4382,8 +4408,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); @@ -4401,8 +4428,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 6620c5bf2..7f87a259c 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -788,7 +788,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // the freshly built module rather than a stale cached copy. const serverMtime = fs.statSync(serverEntryPath).mtimeMs; const serverEntry = await import(`${pathToFileURL(serverEntryPath).href}?t=${serverMtime}`); - const { renderPage, handleApiRoute: handleApi, runMiddleware, vinextConfig } = serverEntry; + const { renderPage, handleApiRoute: handleApi, runMiddleware, vinextConfig, vinextCompiledConfig } = serverEntry; // Load prerender secret written at build time by vinext:server-manifest plugin. // Used to authenticate internal /__vinext/prerender/* HTTP endpoints. @@ -805,6 +805,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), @@ -1017,7 +1020,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. @@ -1135,7 +1138,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") { @@ -1159,7 +1162,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); @@ -1206,7 +1214,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); @@ -1229,6 +1242,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { resolvedPathname, configRewrites.fallback, postMwReqCtx, + compiledRewrites?.fallback, ); if (fallbackRewrite) { if (isExternalUrl(fallbackRewrite)) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 81b35ab74..181c7dbb3 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1454,6 +1454,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -1770,7 +1773,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 @@ -1865,7 +1868,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 && @@ -1900,7 +1903,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); @@ -2170,7 +2173,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); @@ -2185,7 +2193,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); @@ -4440,6 +4453,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"]; @@ -4756,7 +4772,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 @@ -4854,7 +4870,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 && @@ -4889,7 +4905,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); @@ -5159,7 +5175,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); @@ -5174,7 +5195,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); @@ -7459,6 +7485,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -7775,7 +7804,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 @@ -7870,7 +7899,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 && @@ -7905,7 +7934,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); @@ -8175,7 +8204,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); @@ -8190,7 +8224,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); @@ -10483,6 +10522,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -10802,7 +10844,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 @@ -10897,7 +10939,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 && @@ -10932,7 +10974,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); @@ -11202,7 +11244,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); @@ -11217,7 +11264,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); @@ -13479,6 +13531,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -13795,7 +13850,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 @@ -13890,7 +13945,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 && @@ -13925,7 +13980,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); @@ -14195,7 +14250,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); @@ -14210,7 +14270,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); @@ -16694,6 +16759,9 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __compiledRedirects = []; +const __compiledRewrites = { beforeFiles: [], afterFiles: [], fallback: [] }; +const __compiledHeaders = []; const __allowedOrigins = []; @@ -17010,7 +17078,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 @@ -17105,7 +17173,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 && @@ -17271,7 +17339,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); @@ -17541,7 +17609,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); @@ -17556,7 +17629,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); @@ -19298,6 +19376,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 931779a23..c7aaa0d0b 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2508,6 +2508,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"); @@ -2523,6 +2524,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"); @@ -2537,6 +2539,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"); @@ -2607,7 +2610,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 d06f66bd0..a8b1b9d5c 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) {"); 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); + }); +}); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 45f33bb29..2f4d36738 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4906,6 +4906,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.