Skip to content

feat: precompile next.config matchers at build time#536

Open
SeolJaeHyeok wants to merge 3 commits intocloudflare:mainfrom
SeolJaeHyeok:feat/issue-389-precompile-config-patterns
Open

feat: precompile next.config matchers at build time#536
SeolJaeHyeok wants to merge 3 commits intocloudflare:mainfrom
SeolJaeHyeok:feat/issue-389-precompile-config-patterns

Conversation

@SeolJaeHyeok
Copy link
Contributor

Closes #389

summary

  • extract reusable compileConfigPattern and compileHeaderSourcePattern helpers from config-matchers
  • emit precompiled redirect, rewrite, and header matchers in generated App Router and Pages Router entries
  • use the precompiled matcher state in dev, prod, and deploy request handling paths instead of compiling on the first matching request

what changed

  • added packages/vinext/src/config/precompiled-config.ts to build and serialize precompiled config matcher state
  • updated packages/vinext/src/config/config-matchers.ts to expose reusable compile helpers and optional compiled matcher inputs for redirects, rewrites,
    and headers
  • updated generateRscEntry() to emit __compiledRedirects, __compiledRewrites, and __compiledHeaders alongside the existing config payloads
  • updated generateServerEntry() to export vinextCompiledConfig for Pages Router production and deploy paths
  • updated index.ts, server/prod-server.ts, and deploy.ts to thread the precompiled matcher state through the existing config matching flow
  • added regression coverage for matcher compilation and generated entry output

why

PR #387 removed the steady-state bottleneck by caching compiled config patterns per isolate. That keeps warm isolates fast, but low-traffic routes can still
pay the compilation cost on the first request after a cold start.

This PR moves that work out of the request path by compiling config patterns ahead of time for generated entries, and once during dev startup for the live
config path. Matching behavior stays the same, but the first matching request no longer needs to tokenize patterns, run the safe-regex check, and construct
RegExp instances.

test plan

  • pnpm test tests/shims.test.ts tests/app-router.test.ts tests/entry-templates.test.ts
  • pnpm run typecheck
  • pnpm run lint
  • `pnpm run build

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@536

commit: f1d75fa

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: Precompile config matchers at build time

Good idea — moving regex compilation out of the request path is a clear cold-start win. The refactoring of compileConfigPattern and compileHeaderSourcePattern into reusable helpers is clean, and the threading through all four server paths (dev, App Router entry, Pages Router entry, deploy entry) is thorough.

However, there's one significant correctness issue: the precompiled matchRedirect path regresses to O(n) linear scanning, bypassing the locale-static index optimization that was specifically added to handle apps with 63+ locale-prefixed redirect rules. There are also a couple of smaller issues worth addressing.

Summary of issues

  1. [Blocking] matchRedirect precompiled path bypasses the locale-static index — the if (compiledPatterns) early-return path does a linear scan over all redirects, losing the O(1) map-lookup optimization for locale-prefixed rules. For apps with many locale redirects, this reintroduces the exact O(n × regex) cost that the index was built to eliminate.

  2. [Non-blocking] No serialization roundtrip test — the tests verify that generated code contains __compiledRedirects and regex-looking strings, but no test evaluates the serialized code to confirm the regex survives the RegExp.toString() → code-embed → eval cycle. A single roundtrip test would catch subtle serialization bugs (e.g., regex flags, special characters in param names).

  3. [Non-blocking] serializeCompiledPattern doesn't escape paramNamesJSON.stringify handles this correctly for normal param names, but it's worth noting that if a param name ever contained characters that are special in JSON strings (unlikely but possible with future pattern extensions), the current approach is safe because of JSON.stringify.

  4. [Non-blocking] Comment stale in matchHeaders — the comment on line 1107 still references escapeHeaderSource() + safeRegExp() as the cache rationale, but the code now has a two-tier lookup (precompiled array first, then module-level cache). The comment should be updated to reflect the new lookup order.

Comment on lines +778 to +796
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Blocking: This precompiled fast path does a linear O(n) scan over all redirects, which bypasses the locale-static index optimization (_getRedirectIndex) that exists specifically for apps with many locale-prefixed redirect rules (63+ rules in the motivating profile).

When compiledPatterns is passed, the locale-static O(1) map lookup + tiny-regex validation is never used — every request linearly tests every redirect pattern. For simple apps this is fine, but for the apps that motivated the locale-static index, this is a regression.

Suggested approach: instead of a separate early-return branch, integrate the precompiled patterns into the existing matchRedirect flow. The locale-static index handles locale patterns; for the linear fallback rules, you could pass the precompiled pattern by original index so matchConfigPattern is skipped when a precompiled version exists. Something like:

// In the linear fallback loop:
for (const [origIdx, redirect] of index.linear) {
  if (origIdx >= localeMatchIndex) break;
  const compiled = compiledPatterns?.[origIdx];
  const params = compiled
    ? execCompiledConfigPattern(pathname, compiled)
    : matchConfigPattern(pathname, redirect.source);
  // ...
}

This preserves the locale-static fast path while still benefiting from precompilation for non-locale rules.

// 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];
Copy link
Contributor

Choose a reason for hiding this comment

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

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

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


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

Choose a reason for hiding this comment

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

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

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

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

This would catch any edge cases in the serialization format.

}

export function compileConfigPattern(pattern: string): CompiledConfigPattern | null {
if (!usesRegexBranch(pattern)) return null;
Copy link
Contributor

Choose a reason for hiding this comment

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

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

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

@@ -1472,7 +1477,7 @@ export default async function handler(request, ctx) {
let pathname;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: this line is quite long (exceeds typical line width). Consider breaking it for readability, matching the style used for the afterFiles and fallback calls below:

Suggested change
let pathname;
const __rewritten = matchRewrite(
cleanPathname,
__configRewrites.beforeFiles,
__postMwReqCtx,
__compiledRewrites.beforeFiles,
);

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 17, 2026

Review submitted with "Changes Requested". Here's a summary of the feedback:

Blocking issue:

The precompiled matchRedirect path (config-matchers.ts:778-796) introduces a separate if (compiledPatterns) branch that does a linear O(n) scan over all redirects, completely bypassing the locale-static index optimization. This index was specifically built to handle apps with 63+ locale-prefixed redirect rules (reducing cost from O(n × regex) to O(1) map lookup). The precompiled path should be integrated into the existing index-based flow rather than replacing it — e.g., passing precompiled patterns by original index into the linear fallback loop while keeping the locale-static fast path intact.

Non-blocking items:

  1. No serialization roundtrip test — tests check that generated code contains the right variable names, but nothing evaluates the serialized regex code to verify it actually works after the RegExp.toString() → code embed cycle.
  2. Stale comment in matchHeaders — the comment still describes the old single-tier cache, but the code now has a two-tier lookup (precompiled array → module-level cache).
  3. Long line in app-rsc-entry.ts:1477 — the beforeFiles matchRewrite call should be broken across multiple lines like the afterFiles and fallback calls below it.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: emit pre-compiled regex literals for config patterns at build time

2 participants