vinext 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +89 -85
  2. package/dist/build/static-export.d.ts +1 -1
  3. package/dist/build/static-export.d.ts.map +1 -1
  4. package/dist/build/static-export.js +5 -9
  5. package/dist/build/static-export.js.map +1 -1
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +152 -48
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.js +10 -11
  10. package/dist/cli.js.map +1 -1
  11. package/dist/cloudflare/kv-cache-handler.d.ts +43 -1
  12. package/dist/cloudflare/kv-cache-handler.d.ts.map +1 -1
  13. package/dist/cloudflare/kv-cache-handler.js +135 -44
  14. package/dist/cloudflare/kv-cache-handler.js.map +1 -1
  15. package/dist/cloudflare/tpr.d.ts.map +1 -1
  16. package/dist/cloudflare/tpr.js +15 -4
  17. package/dist/cloudflare/tpr.js.map +1 -1
  18. package/dist/config/config-matchers.d.ts +28 -0
  19. package/dist/config/config-matchers.d.ts.map +1 -1
  20. package/dist/config/config-matchers.js +353 -79
  21. package/dist/config/config-matchers.js.map +1 -1
  22. package/dist/config/dotenv.d.ts.map +1 -1
  23. package/dist/config/dotenv.js +1 -6
  24. package/dist/config/dotenv.js.map +1 -1
  25. package/dist/config/next-config.d.ts +7 -0
  26. package/dist/config/next-config.d.ts.map +1 -1
  27. package/dist/config/next-config.js +44 -19
  28. package/dist/config/next-config.js.map +1 -1
  29. package/dist/deploy.d.ts +1 -1
  30. package/dist/deploy.d.ts.map +1 -1
  31. package/dist/deploy.js +81 -48
  32. package/dist/deploy.js.map +1 -1
  33. package/dist/entries/app-rsc-entry.d.ts +3 -1
  34. package/dist/entries/app-rsc-entry.d.ts.map +1 -1
  35. package/dist/entries/app-rsc-entry.js +584 -113
  36. package/dist/entries/app-rsc-entry.js.map +1 -1
  37. package/dist/entries/pages-client-entry.d.ts.map +1 -1
  38. package/dist/entries/pages-client-entry.js +5 -3
  39. package/dist/entries/pages-client-entry.js.map +1 -1
  40. package/dist/entries/pages-server-entry.d.ts.map +1 -1
  41. package/dist/entries/pages-server-entry.js +100 -32
  42. package/dist/entries/pages-server-entry.js.map +1 -1
  43. package/dist/index.d.ts +24 -8
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +327 -154
  46. package/dist/index.js.map +1 -1
  47. package/dist/init.d.ts.map +1 -1
  48. package/dist/init.js +6 -5
  49. package/dist/init.js.map +1 -1
  50. package/dist/plugins/client-reference-dedup.d.ts +19 -0
  51. package/dist/plugins/client-reference-dedup.d.ts.map +1 -0
  52. package/dist/plugins/client-reference-dedup.js +96 -0
  53. package/dist/plugins/client-reference-dedup.js.map +1 -0
  54. package/dist/routing/app-router.d.ts +2 -0
  55. package/dist/routing/app-router.d.ts.map +1 -1
  56. package/dist/routing/app-router.js +70 -107
  57. package/dist/routing/app-router.js.map +1 -1
  58. package/dist/routing/file-matcher.d.ts.map +1 -1
  59. package/dist/routing/file-matcher.js.map +1 -1
  60. package/dist/routing/pages-router.d.ts +3 -1
  61. package/dist/routing/pages-router.d.ts.map +1 -1
  62. package/dist/routing/pages-router.js +33 -18
  63. package/dist/routing/pages-router.js.map +1 -1
  64. package/dist/routing/route-validation.d.ts +8 -0
  65. package/dist/routing/route-validation.d.ts.map +1 -0
  66. package/dist/routing/route-validation.js +124 -0
  67. package/dist/routing/route-validation.js.map +1 -0
  68. package/dist/routing/utils.d.ts.map +1 -1
  69. package/dist/routing/utils.js.map +1 -1
  70. package/dist/server/api-handler.d.ts.map +1 -1
  71. package/dist/server/api-handler.js +31 -9
  72. package/dist/server/api-handler.js.map +1 -1
  73. package/dist/server/app-router-entry.d.ts +3 -2
  74. package/dist/server/app-router-entry.d.ts.map +1 -1
  75. package/dist/server/app-router-entry.js +8 -4
  76. package/dist/server/app-router-entry.js.map +1 -1
  77. package/dist/server/dev-module-runner.d.ts.map +1 -1
  78. package/dist/server/dev-module-runner.js +1 -1
  79. package/dist/server/dev-module-runner.js.map +1 -1
  80. package/dist/server/dev-origin-check.d.ts.map +1 -1
  81. package/dist/server/dev-origin-check.js.map +1 -1
  82. package/dist/server/dev-server.d.ts.map +1 -1
  83. package/dist/server/dev-server.js +39 -21
  84. package/dist/server/dev-server.js.map +1 -1
  85. package/dist/server/image-optimization.d.ts.map +1 -1
  86. package/dist/server/image-optimization.js.map +1 -1
  87. package/dist/server/instrumentation.js +1 -1
  88. package/dist/server/instrumentation.js.map +1 -1
  89. package/dist/server/isr-cache.d.ts +5 -1
  90. package/dist/server/isr-cache.d.ts.map +1 -1
  91. package/dist/server/isr-cache.js +13 -3
  92. package/dist/server/isr-cache.js.map +1 -1
  93. package/dist/server/metadata-routes.d.ts +8 -2
  94. package/dist/server/metadata-routes.d.ts.map +1 -1
  95. package/dist/server/metadata-routes.js +78 -45
  96. package/dist/server/metadata-routes.js.map +1 -1
  97. package/dist/server/middleware-codegen.d.ts +1 -1
  98. package/dist/server/middleware-codegen.d.ts.map +1 -1
  99. package/dist/server/middleware-codegen.js +177 -22
  100. package/dist/server/middleware-codegen.js.map +1 -1
  101. package/dist/server/middleware-request-headers.d.ts +9 -0
  102. package/dist/server/middleware-request-headers.d.ts.map +1 -0
  103. package/dist/server/middleware-request-headers.js +77 -0
  104. package/dist/server/middleware-request-headers.js.map +1 -0
  105. package/dist/server/middleware.d.ts +9 -8
  106. package/dist/server/middleware.d.ts.map +1 -1
  107. package/dist/server/middleware.js +112 -32
  108. package/dist/server/middleware.js.map +1 -1
  109. package/dist/server/normalize-path.js.map +1 -1
  110. package/dist/server/prod-server.d.ts +1 -1
  111. package/dist/server/prod-server.d.ts.map +1 -1
  112. package/dist/server/prod-server.js +127 -82
  113. package/dist/server/prod-server.js.map +1 -1
  114. package/dist/server/request-pipeline.d.ts +2 -1
  115. package/dist/server/request-pipeline.d.ts.map +1 -1
  116. package/dist/server/request-pipeline.js +5 -7
  117. package/dist/server/request-pipeline.js.map +1 -1
  118. package/dist/shims/cache-runtime.d.ts.map +1 -1
  119. package/dist/shims/cache-runtime.js +21 -16
  120. package/dist/shims/cache-runtime.js.map +1 -1
  121. package/dist/shims/cache.d.ts +2 -0
  122. package/dist/shims/cache.d.ts.map +1 -1
  123. package/dist/shims/cache.js +38 -25
  124. package/dist/shims/cache.js.map +1 -1
  125. package/dist/shims/constants.d.ts.map +1 -1
  126. package/dist/shims/constants.js +1 -6
  127. package/dist/shims/constants.js.map +1 -1
  128. package/dist/shims/dynamic.d.ts.map +1 -1
  129. package/dist/shims/dynamic.js +1 -1
  130. package/dist/shims/dynamic.js.map +1 -1
  131. package/dist/shims/error-boundary.d.ts.map +1 -1
  132. package/dist/shims/error-boundary.js +2 -3
  133. package/dist/shims/error-boundary.js.map +1 -1
  134. package/dist/shims/error.d.ts.map +1 -1
  135. package/dist/shims/error.js +1 -3
  136. package/dist/shims/error.js.map +1 -1
  137. package/dist/shims/fetch-cache.d.ts.map +1 -1
  138. package/dist/shims/fetch-cache.js +57 -30
  139. package/dist/shims/fetch-cache.js.map +1 -1
  140. package/dist/shims/font-google-base.d.ts.map +1 -1
  141. package/dist/shims/font-google-base.js +16 -4
  142. package/dist/shims/font-google-base.js.map +1 -1
  143. package/dist/shims/font-google.d.ts +1 -1
  144. package/dist/shims/font-google.d.ts.map +1 -1
  145. package/dist/shims/font-google.generated.d.ts.map +1 -1
  146. package/dist/shims/font-google.generated.js +412 -206
  147. package/dist/shims/font-google.generated.js.map +1 -1
  148. package/dist/shims/font-google.js +1 -1
  149. package/dist/shims/font-google.js.map +1 -1
  150. package/dist/shims/font-local.d.ts.map +1 -1
  151. package/dist/shims/font-local.js +13 -3
  152. package/dist/shims/font-local.js.map +1 -1
  153. package/dist/shims/form.d.ts.map +1 -1
  154. package/dist/shims/form.js +105 -10
  155. package/dist/shims/form.js.map +1 -1
  156. package/dist/shims/head.d.ts.map +1 -1
  157. package/dist/shims/head.js +10 -8
  158. package/dist/shims/head.js.map +1 -1
  159. package/dist/shims/headers.d.ts +34 -8
  160. package/dist/shims/headers.d.ts.map +1 -1
  161. package/dist/shims/headers.js +268 -53
  162. package/dist/shims/headers.js.map +1 -1
  163. package/dist/shims/image.d.ts.map +1 -1
  164. package/dist/shims/image.js +35 -8
  165. package/dist/shims/image.js.map +1 -1
  166. package/dist/shims/internal/parse-cookie-header.d.ts +12 -0
  167. package/dist/shims/internal/parse-cookie-header.d.ts.map +1 -0
  168. package/dist/shims/internal/parse-cookie-header.js +32 -0
  169. package/dist/shims/internal/parse-cookie-header.js.map +1 -0
  170. package/dist/shims/legacy-image.d.ts.map +1 -1
  171. package/dist/shims/legacy-image.js +1 -1
  172. package/dist/shims/legacy-image.js.map +1 -1
  173. package/dist/shims/link.d.ts +2 -1
  174. package/dist/shims/link.d.ts.map +1 -1
  175. package/dist/shims/link.js +37 -17
  176. package/dist/shims/link.js.map +1 -1
  177. package/dist/shims/metadata.d.ts +12 -2
  178. package/dist/shims/metadata.d.ts.map +1 -1
  179. package/dist/shims/metadata.js +10 -8
  180. package/dist/shims/metadata.js.map +1 -1
  181. package/dist/shims/navigation-state.d.ts.map +1 -1
  182. package/dist/shims/navigation-state.js +3 -2
  183. package/dist/shims/navigation-state.js.map +1 -1
  184. package/dist/shims/navigation.d.ts +3 -7
  185. package/dist/shims/navigation.d.ts.map +1 -1
  186. package/dist/shims/navigation.js +46 -29
  187. package/dist/shims/navigation.js.map +1 -1
  188. package/dist/shims/readonly-url-search-params.d.ts +11 -0
  189. package/dist/shims/readonly-url-search-params.d.ts.map +1 -0
  190. package/dist/shims/readonly-url-search-params.js +24 -0
  191. package/dist/shims/readonly-url-search-params.js.map +1 -0
  192. package/dist/shims/request-context.d.ts +50 -0
  193. package/dist/shims/request-context.d.ts.map +1 -0
  194. package/dist/shims/request-context.js +59 -0
  195. package/dist/shims/request-context.js.map +1 -0
  196. package/dist/shims/router-state.d.ts.map +1 -1
  197. package/dist/shims/router-state.js +2 -1
  198. package/dist/shims/router-state.js.map +1 -1
  199. package/dist/shims/router.d.ts +4 -3
  200. package/dist/shims/router.d.ts.map +1 -1
  201. package/dist/shims/router.js +59 -53
  202. package/dist/shims/router.js.map +1 -1
  203. package/dist/shims/script.d.ts.map +1 -1
  204. package/dist/shims/script.js.map +1 -1
  205. package/dist/shims/server.d.ts +14 -1
  206. package/dist/shims/server.d.ts.map +1 -1
  207. package/dist/shims/server.js +107 -47
  208. package/dist/shims/server.js.map +1 -1
  209. package/dist/shims/url-utils.d.ts.map +1 -1
  210. package/dist/shims/url-utils.js +1 -3
  211. package/dist/shims/url-utils.js.map +1 -1
  212. package/dist/utils/base-path.d.ts +17 -0
  213. package/dist/utils/base-path.d.ts.map +1 -0
  214. package/dist/utils/base-path.js +25 -0
  215. package/dist/utils/base-path.js.map +1 -0
  216. package/dist/utils/manifest-paths.d.ts +4 -0
  217. package/dist/utils/manifest-paths.d.ts.map +1 -0
  218. package/dist/utils/manifest-paths.js +20 -0
  219. package/dist/utils/manifest-paths.js.map +1 -0
  220. package/dist/utils/project.d.ts.map +1 -1
  221. package/dist/utils/project.js +2 -4
  222. package/dist/utils/project.js.map +1 -1
  223. package/dist/utils/query.d.ts +9 -0
  224. package/dist/utils/query.d.ts.map +1 -1
  225. package/dist/utils/query.js +59 -7
  226. package/dist/utils/query.js.map +1 -1
  227. package/package.json +47 -33
@@ -4,6 +4,136 @@
4
4
  * Shared between the dev server (index.ts) and the production server
5
5
  * (prod-server.ts) so both apply next.config.js rules identically.
6
6
  */
7
+ import { buildRequestHeadersFromMiddlewareResponse } from "../server/middleware-request-headers.js";
8
+ /**
9
+ * Cache for compiled regex patterns in matchConfigPattern.
10
+ *
11
+ * Redirect/rewrite patterns are static — they come from next.config.js and
12
+ * never change at runtime. Without caching, every request that hits the regex
13
+ * branch re-runs the full tokeniser walk + isSafeRegex + new RegExp() for
14
+ * every rule in the array. On apps with many locale-prefixed rules (which all
15
+ * contain `(` and therefore enter the regex branch) this dominated profiling
16
+ * at ~2.4 seconds of CPU self-time.
17
+ *
18
+ * Value is `null` when safeRegExp rejected the pattern (ReDoS risk), so we
19
+ * skip it on subsequent requests too without re-running the scanner.
20
+ */
21
+ const _compiledPatternCache = new Map();
22
+ /**
23
+ * Cache for compiled header source regexes in matchHeaders.
24
+ *
25
+ * Each NextHeader rule has a `source` that is run through escapeHeaderSource()
26
+ * then safeRegExp() to produce a RegExp. Both are pure functions of the source
27
+ * string and the result never changes. Without caching, every request
28
+ * re-runs the full escapeHeaderSource tokeniser + isSafeRegex scan + new RegExp()
29
+ * for every header rule.
30
+ *
31
+ * Value is `null` when safeRegExp rejected the pattern (ReDoS risk).
32
+ */
33
+ const _compiledHeaderSourceCache = new Map();
34
+ /**
35
+ * Cache for compiled has/missing condition value regexes in checkSingleCondition.
36
+ *
37
+ * Each has/missing condition may carry a `value` string that is passed directly
38
+ * to safeRegExp() for matching against header/cookie/query/host values. The
39
+ * condition objects are static (from next.config.js) so the compiled RegExp
40
+ * never changes. Without caching, safeRegExp() is called on every request for
41
+ * every condition on every rule.
42
+ *
43
+ * Value is `null` when safeRegExp rejected the pattern, or `false` when the
44
+ * value string was undefined (no regex needed — use exact string comparison).
45
+ */
46
+ const _compiledConditionCache = new Map();
47
+ /**
48
+ * Cache for destination substitution regexes in substituteDestinationParams.
49
+ *
50
+ * The regex depends only on the set of param keys captured from the matched
51
+ * source pattern. Caching by sorted key list avoids recompiling a new RegExp
52
+ * for repeated redirect/rewrite calls that use the same param shape.
53
+ */
54
+ const _compiledDestinationParamCache = new Map();
55
+ /**
56
+ * Redirect index for O(1) locale-static rule lookup.
57
+ *
58
+ * Many Next.js apps generate 50-100 redirect rules of the form:
59
+ * /:locale(en|es|fr|...)?/some-static-path → /some-destination
60
+ *
61
+ * The compiled regex for each is like:
62
+ * ^/(en|es|fr|...)?/some-static-path$
63
+ *
64
+ * When no redirect matches (the common case for ordinary page loads),
65
+ * matchRedirect previously ran exec() on every one of those regexes —
66
+ * ~2ms per call, ~2992ms total self-time in profiles.
67
+ *
68
+ * The index splits rules into two buckets:
69
+ *
70
+ * localeStatic — rules whose source is exactly /:paramName(alt1|alt2|...)?/suffix
71
+ * where `suffix` is a static path with no further params or regex groups.
72
+ * These are indexed in a Map<suffix, entry[]> for O(1) lookup after a
73
+ * single fast strip of the optional locale prefix.
74
+ *
75
+ * linear — all other rules. Matched with the original O(n) loop.
76
+ *
77
+ * The index is stored in a WeakMap keyed by the redirects array so it is
78
+ * computed once per config load and GC'd when the array is no longer live.
79
+ *
80
+ * ## Ordering invariant
81
+ *
82
+ * Redirect rules must be evaluated in their original order (first match wins).
83
+ * Each locale-static entry stores its `originalIndex` so that, when a
84
+ * locale-static fast-path match is found, any linear rules that appear earlier
85
+ * in the array are still checked first.
86
+ */
87
+ /** Matches `/:param(alternation)?/static/suffix` — the locale-static pattern. */
88
+ const _LOCALE_STATIC_RE = /^\/:[\w-]+\(([^)]+)\)\?\/([a-zA-Z0-9_~.%@!$&'*+,;=:/-]+)$/;
89
+ const _redirectIndexCache = new WeakMap();
90
+ /**
91
+ * Build (or retrieve from cache) the redirect index for a given redirects array.
92
+ *
93
+ * Called once per config load from matchRedirect. The WeakMap ensures the index
94
+ * is recomputed if the config is reloaded (new array reference) and GC'd when
95
+ * the array is collected.
96
+ */
97
+ function _getRedirectIndex(redirects) {
98
+ let index = _redirectIndexCache.get(redirects);
99
+ if (index !== undefined)
100
+ return index;
101
+ const localeStatic = new Map();
102
+ const linear = [];
103
+ for (let i = 0; i < redirects.length; i++) {
104
+ const redirect = redirects[i];
105
+ const m = _LOCALE_STATIC_RE.exec(redirect.source);
106
+ if (m) {
107
+ const paramName = redirect.source.slice(2, redirect.source.indexOf("("));
108
+ const alternation = m[1];
109
+ const suffix = "/" + m[2]; // e.g. "/security"
110
+ // Build a small regex to validate the captured locale value against the
111
+ // alternation. Using anchored match to avoid partial matches.
112
+ // The alternation comes from user config; run it through safeRegExp to
113
+ // guard against ReDoS in pathological configs.
114
+ const altRe = safeRegExp("^(?:" + alternation + ")$");
115
+ if (!altRe) {
116
+ // Unsafe alternation — fall back to linear scan for this rule.
117
+ linear.push([i, redirect]);
118
+ continue;
119
+ }
120
+ const entry = { paramName, altRe, redirect, originalIndex: i };
121
+ const bucket = localeStatic.get(suffix);
122
+ if (bucket) {
123
+ bucket.push(entry);
124
+ }
125
+ else {
126
+ localeStatic.set(suffix, [entry]);
127
+ }
128
+ }
129
+ else {
130
+ linear.push([i, redirect]);
131
+ }
132
+ }
133
+ index = { localeStatic, linear };
134
+ _redirectIndexCache.set(redirects, index);
135
+ return index;
136
+ }
7
137
  /** Hop-by-hop headers that should not be forwarded through a proxy. */
8
138
  const HOP_BY_HOP_HEADERS = new Set([
9
139
  "connection",
@@ -238,9 +368,13 @@ export function requestContextFromRequest(request) {
238
368
  headers: request.headers,
239
369
  cookies: parseCookies(request.headers.get("cookie")),
240
370
  query: url.searchParams,
241
- host: request.headers.get("host") ?? url.host,
371
+ host: normalizeHost(request.headers.get("host"), url.hostname),
242
372
  };
243
373
  }
374
+ export function normalizeHost(hostHeader, fallbackHostname) {
375
+ const host = hostHeader ?? fallbackHostname;
376
+ return host.split(":", 1)[0].toLowerCase();
377
+ }
244
378
  /**
245
379
  * Unpack `x-middleware-request-*` headers from the collected middleware
246
380
  * response headers into the actual request, and strip all `x-middleware-*`
@@ -259,27 +393,17 @@ export function requestContextFromRequest(request) {
259
393
  * `middlewareHeaders` is safe to cast here.
260
394
  */
261
395
  export function applyMiddlewareRequestHeaders(middlewareHeaders, request) {
262
- const mwReqPrefix = "x-middleware-request-";
263
- const toApply = {};
396
+ const nextHeaders = buildRequestHeadersFromMiddlewareResponse(request.headers, middlewareHeaders);
264
397
  for (const key of Object.keys(middlewareHeaders)) {
265
- if (key.startsWith(mwReqPrefix)) {
266
- const realName = key.slice(mwReqPrefix.length);
267
- toApply[realName] = middlewareHeaders[key];
268
- delete middlewareHeaders[key];
269
- }
270
- else if (key.startsWith("x-middleware-")) {
398
+ if (key.startsWith("x-middleware-")) {
271
399
  delete middlewareHeaders[key];
272
400
  }
273
401
  }
274
- if (Object.keys(toApply).length > 0) {
402
+ if (nextHeaders) {
275
403
  // Headers may be immutable (Workers), so always clone via new Headers().
276
- const newHeaders = new Headers(request.headers);
277
- for (const [k, v] of Object.entries(toApply)) {
278
- newHeaders.set(k, v);
279
- }
280
404
  request = new Request(request.url, {
281
405
  method: request.method,
282
- headers: newHeaders,
406
+ headers: nextHeaders,
283
407
  body: request.body,
284
408
  // @ts-expect-error — duplex needed for streaming request bodies
285
409
  duplex: request.body ? "half" : undefined,
@@ -298,7 +422,7 @@ function checkSingleCondition(condition, ctx) {
298
422
  if (headerValue === null)
299
423
  return false;
300
424
  if (condition.value !== undefined) {
301
- const re = safeRegExp(condition.value);
425
+ const re = _cachedConditionRegex(condition.value);
302
426
  if (re)
303
427
  return re.test(headerValue);
304
428
  return headerValue === condition.value;
@@ -310,7 +434,7 @@ function checkSingleCondition(condition, ctx) {
310
434
  if (cookieValue === undefined)
311
435
  return false;
312
436
  if (condition.value !== undefined) {
313
- const re = safeRegExp(condition.value);
437
+ const re = _cachedConditionRegex(condition.value);
314
438
  if (re)
315
439
  return re.test(cookieValue);
316
440
  return cookieValue === condition.value;
@@ -322,7 +446,7 @@ function checkSingleCondition(condition, ctx) {
322
446
  if (queryValue === null)
323
447
  return false;
324
448
  if (condition.value !== undefined) {
325
- const re = safeRegExp(condition.value);
449
+ const re = _cachedConditionRegex(condition.value);
326
450
  if (re)
327
451
  return re.test(queryValue);
328
452
  return queryValue === condition.value;
@@ -331,7 +455,7 @@ function checkSingleCondition(condition, ctx) {
331
455
  }
332
456
  case "host": {
333
457
  if (condition.value !== undefined) {
334
- const re = safeRegExp(condition.value);
458
+ const re = _cachedConditionRegex(condition.value);
335
459
  if (re)
336
460
  return re.test(ctx.host);
337
461
  return ctx.host === condition.value;
@@ -342,6 +466,19 @@ function checkSingleCondition(condition, ctx) {
342
466
  return false;
343
467
  }
344
468
  }
469
+ /**
470
+ * Return a cached RegExp for a has/missing condition value string, compiling
471
+ * on first use. Returns null if safeRegExp rejected the pattern or if the
472
+ * value is not a valid regex (fall back to exact string comparison).
473
+ */
474
+ function _cachedConditionRegex(value) {
475
+ let re = _compiledConditionCache.get(value);
476
+ if (re === undefined) {
477
+ re = safeRegExp(value);
478
+ _compiledConditionCache.set(value, re);
479
+ }
480
+ return re;
481
+ }
345
482
  /**
346
483
  * Check all has/missing conditions for a config rule.
347
484
  * Returns true if the rule should be applied (all has conditions pass, all missing conditions pass).
@@ -412,55 +549,65 @@ export function matchConfigPattern(pathname, pattern) {
412
549
  /:[\w-]+[*+][^/]/.test(pattern) ||
413
550
  /:[\w-]+\./.test(pattern)) {
414
551
  try {
415
- // Param names may contain hyphens (e.g. :auth-method, :sign-in).
416
- const paramNames = [];
417
- // Single-pass conversion with procedural suffix handling. The tokenizer
418
- // matches only simple, non-overlapping tokens; quantifier/constraint
419
- // suffixes after :param are consumed procedurally to avoid polynomial
420
- // backtracking in the regex engine.
421
- let regexStr = "";
422
- const tokenRe = /:([\w-]+)|[.]|[^:.]+/g; // lgtm[js/redos] — alternatives are non-overlapping (`:` and `.` excluded from `[^:.]+`)
423
- let tok;
424
- while ((tok = tokenRe.exec(pattern)) !== null) {
425
- if (tok[1] !== undefined) {
426
- const name = tok[1];
427
- const rest = pattern.slice(tokenRe.lastIndex);
428
- // Check for quantifier (* or +) with optional constraint
429
- if (rest.startsWith("*") || rest.startsWith("+")) {
430
- const quantifier = rest[0];
431
- tokenRe.lastIndex += 1;
432
- const constraint = extractConstraint(pattern, tokenRe);
433
- paramNames.push(name);
434
- if (constraint !== null) {
435
- regexStr += `(${constraint})`;
552
+ // Look up the compiled regex in the module-level cache. Patterns come
553
+ // from next.config.js and are static, so we only need to compile each
554
+ // one once across the lifetime of the worker/server process.
555
+ let compiled = _compiledPatternCache.get(pattern);
556
+ if (compiled === undefined) {
557
+ // Cache miss — compile the pattern now and store the result.
558
+ // Param names may contain hyphens (e.g. :auth-method, :sign-in).
559
+ const paramNames = [];
560
+ // Single-pass conversion with procedural suffix handling. The tokenizer
561
+ // matches only simple, non-overlapping tokens; quantifier/constraint
562
+ // suffixes after :param are consumed procedurally to avoid polynomial
563
+ // backtracking in the regex engine.
564
+ let regexStr = "";
565
+ const tokenRe = /:([\w-]+)|[.]|[^:.]+/g; // lgtm[js/redos] alternatives are non-overlapping (`:` and `.` excluded from `[^:.]+`)
566
+ let tok;
567
+ while ((tok = tokenRe.exec(pattern)) !== null) {
568
+ if (tok[1] !== undefined) {
569
+ const name = tok[1];
570
+ const rest = pattern.slice(tokenRe.lastIndex);
571
+ // Check for quantifier (* or +) with optional constraint
572
+ if (rest.startsWith("*") || rest.startsWith("+")) {
573
+ const quantifier = rest[0];
574
+ tokenRe.lastIndex += 1;
575
+ const constraint = extractConstraint(pattern, tokenRe);
576
+ paramNames.push(name);
577
+ if (constraint !== null) {
578
+ regexStr += `(${constraint})`;
579
+ }
580
+ else {
581
+ regexStr += quantifier === "*" ? "(.*)" : "(.+)";
582
+ }
436
583
  }
437
584
  else {
438
- regexStr += quantifier === "*" ? "(.*)" : "(.+)";
585
+ // Check for inline constraint without quantifier
586
+ const constraint = extractConstraint(pattern, tokenRe);
587
+ paramNames.push(name);
588
+ regexStr += constraint !== null ? `(${constraint})` : "([^/]+)";
439
589
  }
440
590
  }
591
+ else if (tok[0] === ".") {
592
+ regexStr += "\\.";
593
+ }
441
594
  else {
442
- // Check for inline constraint without quantifier
443
- const constraint = extractConstraint(pattern, tokenRe);
444
- paramNames.push(name);
445
- regexStr += constraint !== null ? `(${constraint})` : "([^/]+)";
595
+ regexStr += tok[0];
446
596
  }
447
597
  }
448
- else if (tok[0] === ".") {
449
- regexStr += "\\.";
450
- }
451
- else {
452
- regexStr += tok[0];
453
- }
598
+ const re = safeRegExp("^" + regexStr + "$");
599
+ // Store null for rejected patterns so we don't re-run isSafeRegex.
600
+ compiled = re ? { re, paramNames } : null;
601
+ _compiledPatternCache.set(pattern, compiled);
454
602
  }
455
- const re = safeRegExp("^" + regexStr + "$");
456
- if (!re)
603
+ if (!compiled)
457
604
  return null;
458
- const match = re.exec(pathname);
605
+ const match = compiled.re.exec(pathname);
459
606
  if (!match)
460
607
  return null;
461
608
  const params = Object.create(null);
462
- for (let i = 0; i < paramNames.length; i++) {
463
- params[paramNames[i]] = match[i + 1] ?? "";
609
+ for (let i = 0; i < compiled.paramNames.length; i++) {
610
+ params[compiled.paramNames[i]] = match[i + 1] ?? "";
464
611
  }
465
612
  return params;
466
613
  }
@@ -512,9 +659,115 @@ export function matchConfigPattern(pathname, pattern) {
512
659
  * `ctx` provides the request context (cookies, headers, query, host) used
513
660
  * to evaluate has/missing conditions. Next.js always has request context
514
661
  * when evaluating redirects, so this parameter is required.
662
+ *
663
+ * ## Performance
664
+ *
665
+ * Rules with a locale-capture-group prefix (the dominant pattern in large
666
+ * Next.js apps — e.g. `/:locale(en|es|fr|...)?/some-path`) are handled via
667
+ * a pre-built index. Instead of running exec() on each locale regex
668
+ * individually, we:
669
+ *
670
+ * 1. Strip the optional locale prefix from the pathname with one cheap
671
+ * string-slice check (no regex exec on the hot path).
672
+ * 2. Look up the stripped suffix in a Map<suffix, entry[]>.
673
+ * 3. For each matching entry, validate the captured locale string against
674
+ * a small, anchored alternation regex.
675
+ *
676
+ * This reduces the per-request cost from O(n × regex) to O(1) map lookup +
677
+ * O(matches × tiny-regex), eliminating the ~2992ms self-time reported in
678
+ * profiles for apps with 63+ locale-prefixed rules.
679
+ *
680
+ * Rules that don't fit the locale-static pattern fall back to the original
681
+ * linear matchConfigPattern scan.
682
+ *
683
+ * ## Ordering invariant
684
+ *
685
+ * First match wins, preserving the original redirect array order. When a
686
+ * locale-static fast-path match is found at position N, all linear rules with
687
+ * an original index < N are checked via matchConfigPattern first — they are
688
+ * few in practice (typically zero) so this is not a hot-path concern.
515
689
  */
516
690
  export function matchRedirect(pathname, redirects, ctx) {
517
- for (const redirect of redirects) {
691
+ if (redirects.length === 0)
692
+ return null;
693
+ const index = _getRedirectIndex(redirects);
694
+ // --- Locate the best locale-static candidate ---
695
+ //
696
+ // We look for the locale-static entry with the LOWEST originalIndex that
697
+ // matches this pathname (and passes has/missing conditions).
698
+ //
699
+ // Strategy: try both the full pathname (locale omitted, e.g. "/security")
700
+ // and the pathname with the first segment stripped (locale present, e.g.
701
+ // "/en/security" → suffix "/security", locale "en").
702
+ //
703
+ // We do NOT use a regex here — just a single indexOf('/') to locate the
704
+ // second slash, which is O(n) on the path length but far cheaper than
705
+ // running 63 compiled regexes.
706
+ let localeMatch = null;
707
+ let localeMatchIndex = Infinity;
708
+ if (index.localeStatic.size > 0) {
709
+ // Case 1: no locale prefix — pathname IS the suffix.
710
+ const noLocaleBucket = index.localeStatic.get(pathname);
711
+ if (noLocaleBucket) {
712
+ for (const entry of noLocaleBucket) {
713
+ if (entry.originalIndex >= localeMatchIndex)
714
+ continue; // already have a better match
715
+ const redirect = entry.redirect;
716
+ if (redirect.has || redirect.missing) {
717
+ if (!checkHasConditions(redirect.has, redirect.missing, ctx))
718
+ continue;
719
+ }
720
+ // Locale was omitted (the `?` made it optional) — param value is "".
721
+ let dest = substituteDestinationParams(redirect.destination, {
722
+ [entry.paramName]: "",
723
+ });
724
+ dest = sanitizeDestination(dest);
725
+ localeMatch = { destination: dest, permanent: redirect.permanent };
726
+ localeMatchIndex = entry.originalIndex;
727
+ break; // bucket entries are in insertion order = original order
728
+ }
729
+ }
730
+ // Case 2: locale prefix present — first path segment is the locale.
731
+ // Find the second slash: pathname = "/locale/rest/of/path"
732
+ // ^--- slashTwo
733
+ const slashTwo = pathname.indexOf("/", 1);
734
+ if (slashTwo !== -1) {
735
+ const suffix = pathname.slice(slashTwo); // e.g. "/security"
736
+ const localePart = pathname.slice(1, slashTwo); // e.g. "en"
737
+ const localeBucket = index.localeStatic.get(suffix);
738
+ if (localeBucket) {
739
+ for (const entry of localeBucket) {
740
+ if (entry.originalIndex >= localeMatchIndex)
741
+ continue;
742
+ // Validate that `localePart` is one of the allowed alternation values.
743
+ if (!entry.altRe.test(localePart))
744
+ continue;
745
+ const redirect = entry.redirect;
746
+ if (redirect.has || redirect.missing) {
747
+ if (!checkHasConditions(redirect.has, redirect.missing, ctx))
748
+ continue;
749
+ }
750
+ let dest = substituteDestinationParams(redirect.destination, {
751
+ [entry.paramName]: localePart,
752
+ });
753
+ dest = sanitizeDestination(dest);
754
+ localeMatch = { destination: dest, permanent: redirect.permanent };
755
+ localeMatchIndex = entry.originalIndex;
756
+ break; // bucket entries are in insertion order = original order
757
+ }
758
+ }
759
+ }
760
+ }
761
+ // --- Linear fallback: all non-locale-static rules ---
762
+ //
763
+ // We only need to check linear rules whose originalIndex < localeMatchIndex.
764
+ // If localeMatchIndex is Infinity (no locale match), we check all of them.
765
+ for (const [origIdx, redirect] of index.linear) {
766
+ if (origIdx >= localeMatchIndex) {
767
+ // This linear rule comes after the best locale-static match —
768
+ // the locale-static match wins. Stop scanning.
769
+ break;
770
+ }
518
771
  const params = matchConfigPattern(pathname, redirect.source);
519
772
  if (params) {
520
773
  if (redirect.has || redirect.missing) {
@@ -522,20 +775,14 @@ export function matchRedirect(pathname, redirects, ctx) {
522
775
  continue;
523
776
  }
524
777
  }
525
- let dest = redirect.destination;
526
- for (const [key, value] of Object.entries(params)) {
527
- // Replace :param*, :param+, and :param forms in the destination.
528
- // The catch-all suffixes (* and +) must be stripped along with the param name.
529
- dest = dest.replace(`:${key}*`, value);
530
- dest = dest.replace(`:${key}+`, value);
531
- dest = dest.replace(`:${key}`, value);
532
- }
778
+ let dest = substituteDestinationParams(redirect.destination, params);
533
779
  // Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params).
534
780
  dest = sanitizeDestination(dest);
535
781
  return { destination: dest, permanent: redirect.permanent };
536
782
  }
537
783
  }
538
- return null;
784
+ // Return the locale-static match if found (no earlier linear rule matched).
785
+ return localeMatch;
539
786
  }
540
787
  /**
541
788
  * Apply rewrite rules from next.config.js.
@@ -554,14 +801,7 @@ export function matchRewrite(pathname, rewrites, ctx) {
554
801
  continue;
555
802
  }
556
803
  }
557
- let dest = rewrite.destination;
558
- for (const [key, value] of Object.entries(params)) {
559
- // Replace :param*, :param+, and :param forms in the destination.
560
- // The catch-all suffixes (* and +) must be stripped along with the param name.
561
- dest = dest.replace(`:${key}*`, value);
562
- dest = dest.replace(`:${key}+`, value);
563
- dest = dest.replace(`:${key}`, value);
564
- }
804
+ let dest = substituteDestinationParams(rewrite.destination, params);
565
805
  // Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params).
566
806
  dest = sanitizeDestination(dest);
567
807
  return dest;
@@ -569,6 +809,33 @@ export function matchRewrite(pathname, rewrites, ctx) {
569
809
  }
570
810
  return null;
571
811
  }
812
+ /**
813
+ * Substitute all matched route params into a redirect/rewrite destination.
814
+ *
815
+ * Handles repeated params (e.g. `/api/:id/:id`) and catch-all suffix forms
816
+ * (`:path*`, `:path+`) in a single pass. Unknown params are left intact.
817
+ */
818
+ function substituteDestinationParams(destination, params) {
819
+ const keys = Object.keys(params);
820
+ if (keys.length === 0)
821
+ return destination;
822
+ // Match only the concrete param keys captured from the source pattern.
823
+ // Sorting longest-first ensures hyphenated names like `auth-method`
824
+ // win over shorter prefixes like `auth`. The negative lookahead keeps
825
+ // alphanumeric/underscore suffixes attached, while allowing `-` to act
826
+ // as a literal delimiter in destinations like `:year-:month`.
827
+ const sortedKeys = [...keys].sort((a, b) => b.length - a.length);
828
+ const cacheKey = sortedKeys.join("\0");
829
+ let paramRe = _compiledDestinationParamCache.get(cacheKey);
830
+ if (!paramRe) {
831
+ const paramAlternation = sortedKeys
832
+ .map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
833
+ .join("|");
834
+ paramRe = new RegExp(`:(${paramAlternation})([+*])?(?![A-Za-z0-9_])`, "g");
835
+ _compiledDestinationParamCache.set(cacheKey, paramRe);
836
+ }
837
+ return destination.replace(paramRe, (_token, key) => params[key]);
838
+ }
572
839
  /**
573
840
  * Sanitize a redirect/rewrite destination to collapse protocol-relative URLs.
574
841
  *
@@ -613,12 +880,13 @@ export async function proxyExternalRequest(request, externalUrl) {
613
880
  // Build the full external URL, preserving query parameters from the original request
614
881
  const originalUrl = new URL(request.url);
615
882
  const targetUrl = new URL(externalUrl);
883
+ const destinationKeys = new Set(targetUrl.searchParams.keys());
616
884
  // If the rewrite destination already has query params, merge them.
617
885
  // Destination params take precedence — original request params are only added
618
886
  // when the destination doesn't already specify that key.
619
887
  for (const [key, value] of originalUrl.searchParams) {
620
- if (!targetUrl.searchParams.has(key)) {
621
- targetUrl.searchParams.set(key, value);
888
+ if (!destinationKeys.has(key)) {
889
+ targetUrl.searchParams.append(key, value);
622
890
  }
623
891
  }
624
892
  // Forward the request with appropriate headers
@@ -674,7 +942,7 @@ export async function proxyExternalRequest(request, externalUrl) {
674
942
  // decompression on the already-decoded body, resulting in
675
943
  // ERR_CONTENT_DECODING_FAILED. Strip both headers on Node.js only.
676
944
  // On Workers, fetch() preserves wire encoding, so the headers stay accurate.
677
- const isNodeRuntime = typeof process !== "undefined" && !!(process.versions?.node);
945
+ const isNodeRuntime = typeof process !== "undefined" && !!process.versions?.node;
678
946
  const responseHeaders = new Headers();
679
947
  upstreamResponse.headers.forEach((value, key) => {
680
948
  const lower = key.toLowerCase();
@@ -701,8 +969,14 @@ export async function proxyExternalRequest(request, externalUrl) {
701
969
  export function matchHeaders(pathname, headers, ctx) {
702
970
  const result = [];
703
971
  for (const rule of headers) {
704
- const escaped = escapeHeaderSource(rule.source);
705
- const sourceRegex = safeRegExp("^" + escaped + "$");
972
+ // Cache the compiled source regex — escapeHeaderSource() + safeRegExp() are
973
+ // pure functions of rule.source and the result never changes between requests.
974
+ let sourceRegex = _compiledHeaderSourceCache.get(rule.source);
975
+ if (sourceRegex === undefined) {
976
+ const escaped = escapeHeaderSource(rule.source);
977
+ sourceRegex = safeRegExp("^" + escaped + "$");
978
+ _compiledHeaderSourceCache.set(rule.source, sourceRegex);
979
+ }
706
980
  if (sourceRegex && sourceRegex.test(pathname)) {
707
981
  if (rule.has || rule.missing) {
708
982
  if (!checkHasConditions(rule.has, rule.missing, ctx)) {