Skip to content

Commit 0c2942a

Browse files
committed
vibe code interception route fixes
1 parent eec5c16 commit 0c2942a

File tree

6 files changed

+241
-18
lines changed

6 files changed

+241
-18
lines changed

packages/next/src/lib/generate-interception-routes-rewrites.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { pathToRegexp } from 'next/dist/compiled/path-to-regexp'
21
import { NEXT_URL } from '../client/components/app-router-headers'
32
import {
43
extractInterceptionRouteInformation,
54
isInterceptionRouteAppPath,
65
} from '../shared/lib/router/utils/interception-routes'
76
import type { Rewrite } from './load-custom-routes'
7+
import { safePathToRegexp } from './try-to-parse-path'
88

99
// a function that converts normalised paths (e.g. /foo/[bar]/[baz]) to the format expected by pathToRegexp (e.g. /foo/:bar/:baz)
1010
function toPathToRegexpPath(path: string): string {
@@ -41,7 +41,7 @@ export function generateInterceptionRoutesRewrites(
4141
// pathToRegexp returns a regex that matches the path, but we need to
4242
// convert it to a string that can be used in a header value
4343
// to the format that Next/the proxy expects
44-
let interceptingRouteRegex = pathToRegexp(normalizedInterceptingRoute)
44+
let interceptingRouteRegex = safePathToRegexp(normalizedInterceptingRoute)
4545
.toString()
4646
.slice(2, -3)
4747

packages/next/src/lib/try-to-parse-path.ts

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { Token } from 'next/dist/compiled/path-to-regexp'
2-
import { parse, tokensToRegexp } from 'next/dist/compiled/path-to-regexp'
2+
import {
3+
parse,
4+
tokensToRegexp,
5+
pathToRegexp,
6+
compile,
7+
regexpToFunction,
8+
} from 'next/dist/compiled/path-to-regexp'
39
import { parse as parseURL } from 'url'
410
import isError from './is-error'
511

@@ -34,6 +40,208 @@ function reportError({ route, parsedPath }: ParseResult, err: any) {
3440
}
3541
}
3642

43+
/**
44+
* Fixes tokens that have repeating modifiers (* or +) but empty prefix and suffix.
45+
* This is needed to work around path-to-regexp 6.3.0+ which doesn't allow such tokens.
46+
*/
47+
function fixTokensForRegexp(tokens: Token[]): Token[] {
48+
return tokens.map((token) => {
49+
if (
50+
typeof token === 'object' &&
51+
token !== null &&
52+
'modifier' in token &&
53+
(token.modifier === '*' || token.modifier === '+') &&
54+
'prefix' in token &&
55+
'suffix' in token &&
56+
token.prefix === '' &&
57+
token.suffix === ''
58+
) {
59+
// For tokens with repeating modifiers but no prefix/suffix,
60+
// we need to provide a minimal prefix to satisfy path-to-regexp
61+
return {
62+
...token,
63+
prefix: '/',
64+
}
65+
}
66+
return token
67+
})
68+
}
69+
70+
/**
71+
* Fixes route patterns that have adjacent parameters without text between them.
72+
* This is needed to work around path-to-regexp 6.3.0+ validation.
73+
* We use a special marker that can be stripped out later to avoid parameter pollution.
74+
*/
75+
function fixAdjacentParameters(route: string): string {
76+
let fixed = route
77+
78+
// Use a special marker that users would never add themselves
79+
// and that we can strip out before it leaks into params
80+
const PARAM_SEPARATOR = '__NEXT_SEP__'
81+
82+
// The issue is (.):param - add our special separator
83+
fixed = fixed.replace(/(\([^)]*\)):([^/\s]+)/g, `$1${PARAM_SEPARATOR}:$2`)
84+
85+
// Handle other basic adjacent parameter patterns conservatively
86+
fixed = fixed.replace(/:([^:/\s)]+)(?=:)/g, `:$1${PARAM_SEPARATOR}`)
87+
88+
return fixed
89+
}
90+
91+
/**
92+
* Detects if a route pattern has issues that would cause path-to-regexp 6.3.0+ validation to fail
93+
*/
94+
function hasAdjacentParameterIssues(route: string): boolean {
95+
if (typeof route !== 'string') return false
96+
97+
// Check for interception route markers followed immediately by parameters
98+
// Pattern: (.):param, (..):param, etc.
99+
if (/\([^)]*\):[^/\s]+/.test(route)) {
100+
return true
101+
}
102+
103+
// Check for adjacent parameters without separators
104+
// Pattern: :param1:param2
105+
if (/:([^:/\s)]+):/.test(route)) {
106+
return true
107+
}
108+
109+
return false
110+
}
111+
112+
/**
113+
* Safe wrapper around pathToRegexp that handles path-to-regexp 6.3.0+ validation errors.
114+
* This includes both "Can not repeat without prefix/suffix" and "Must have text between parameters" errors.
115+
*/
116+
export function safePathToRegexp(
117+
route: string | RegExp | Array<string | RegExp>,
118+
keys?: any[],
119+
options?: any
120+
): RegExp {
121+
// Proactively fix known problematic patterns
122+
if (typeof route === 'string' && hasAdjacentParameterIssues(route)) {
123+
const fixedRoute = fixAdjacentParameters(route)
124+
return pathToRegexp(fixedRoute, keys, options)
125+
}
126+
127+
try {
128+
return pathToRegexp(route, keys, options)
129+
} catch (error) {
130+
// For any remaining edge cases, try the fix as fallback
131+
if (isError(error) && typeof route === 'string') {
132+
try {
133+
const fixedRoute = fixAdjacentParameters(route)
134+
return pathToRegexp(fixedRoute, keys, options)
135+
} catch (retryError) {
136+
// If that doesn't work, fall back to original error
137+
throw error
138+
}
139+
}
140+
throw error
141+
}
142+
}
143+
144+
/**
145+
* Safe wrapper around compile that handles path-to-regexp 6.3.0+ validation errors.
146+
*/
147+
export function safeCompile(route: string, options?: any) {
148+
// Proactively fix known problematic patterns
149+
if (hasAdjacentParameterIssues(route)) {
150+
const fixedRoute = fixAdjacentParameters(route)
151+
return compile(fixedRoute, options)
152+
}
153+
154+
try {
155+
return compile(route, options)
156+
} catch (error) {
157+
// For any remaining edge cases, try the fix as fallback
158+
if (isError(error)) {
159+
try {
160+
const fixedRoute = fixAdjacentParameters(route)
161+
return compile(fixedRoute, options)
162+
} catch (retryError) {
163+
// If that doesn't work, fall back to original error
164+
throw error
165+
}
166+
}
167+
throw error
168+
}
169+
}
170+
171+
/**
172+
* Safe wrapper around tokensToRegexp that handles path-to-regexp 6.3.0+ validation errors.
173+
*/
174+
function safeTokensToRegexp(tokens: Token[]): RegExp {
175+
try {
176+
return tokensToRegexp(tokens)
177+
} catch (error) {
178+
if (isError(error)) {
179+
// Try to fix tokens with repeating modifiers but no prefix/suffix
180+
const fixedTokens = fixTokensForRegexp(tokens)
181+
return tokensToRegexp(fixedTokens)
182+
}
183+
throw error
184+
}
185+
}
186+
187+
/**
188+
* Strips the special parameter separator from extracted route parameters.
189+
* This is internal to the safe wrappers and should not be used elsewhere.
190+
*/
191+
function stripParameterSeparators(
192+
params: Record<string, any>
193+
): Record<string, any> {
194+
const PARAM_SEPARATOR = '__NEXT_SEP__'
195+
const cleaned: Record<string, any> = {}
196+
197+
for (const [key, value] of Object.entries(params)) {
198+
if (typeof value === 'string') {
199+
// Remove the separator if it appears at the start of parameter values
200+
cleaned[key] = value.replace(new RegExp(`^${PARAM_SEPARATOR}`), '')
201+
} else {
202+
cleaned[key] = value
203+
}
204+
}
205+
206+
return cleaned
207+
}
208+
209+
/**
210+
* Safe wrapper around regexpToFunction that automatically cleans parameters.
211+
*/
212+
export function safeRegexpToFunction<T = object>(
213+
regexp: RegExp,
214+
keys?: any[]
215+
): (pathname: string) => { params: T } | false {
216+
const originalMatcher = regexpToFunction<T>(regexp, keys)
217+
218+
return (pathname: string) => {
219+
const result = originalMatcher(pathname)
220+
if (!result) return false
221+
222+
// Clean parameters before returning
223+
return {
224+
...result,
225+
params: stripParameterSeparators(result.params as any) as T,
226+
}
227+
}
228+
}
229+
230+
/**
231+
* Safe wrapper for route matcher functions that automatically cleans interception markers.
232+
*/
233+
export function safeRouteMatcher<T extends Record<string, any>>(
234+
matcherFn: (pathname: string) => false | T
235+
): (pathname: string) => false | T {
236+
return (pathname: string) => {
237+
const result = matcherFn(pathname)
238+
if (!result) return false
239+
240+
// Clean parameters before returning
241+
return stripParameterSeparators(result) as T
242+
}
243+
}
244+
37245
/**
38246
* Attempts to parse a given route with `path-to-regexp` and returns an object
39247
* with the result. Whenever an error happens on parse, it will print an error
@@ -55,7 +263,11 @@ export function tryToParsePath(
55263
}
56264

57265
result.tokens = parse(result.parsedPath)
58-
result.regexStr = tokensToRegexp(result.tokens).source
266+
267+
// Use safe wrapper instead of proactive detection
268+
if (result.tokens) {
269+
result.regexStr = safeTokensToRegexp(result.tokens).source
270+
}
59271
} catch (err) {
60272
reportError(result, err)
61273
result.error = err

packages/next/src/shared/lib/router/utils/path-match.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Key } from 'next/dist/compiled/path-to-regexp'
2-
import { pathToRegexp } from 'next/dist/compiled/path-to-regexp'
3-
import { regexpToFunction } from 'next/dist/compiled/path-to-regexp'
2+
import {
3+
safePathToRegexp,
4+
safeRegexpToFunction,
5+
} from '../../../../lib/try-to-parse-path'
46

57
interface Options {
68
/**
@@ -37,14 +39,14 @@ export type PatchMatcher = (
3739
*/
3840
export function getPathMatch(path: string, options?: Options): PatchMatcher {
3941
const keys: Key[] = []
40-
const regexp = pathToRegexp(path, keys, {
42+
const regexp = safePathToRegexp(path, keys, {
4143
delimiter: '/',
4244
sensitive:
4345
typeof options?.sensitive === 'boolean' ? options.sensitive : false,
4446
strict: options?.strict,
4547
})
4648

47-
const matcher = regexpToFunction<Record<string, any>>(
49+
const matcher = safeRegexpToFunction<Record<string, any>>(
4850
options?.regexModifier
4951
? new RegExp(options.regexModifier(regexp.source), regexp.flags)
5052
: regexp,

packages/next/src/shared/lib/router/utils/prepare-destination.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { NextParsedUrlQuery } from '../../../../server/request-meta'
44
import type { RouteHas } from '../../../../lib/load-custom-routes'
55
import type { BaseNextRequest } from '../../../../server/base-http'
66

7-
import { compile, pathToRegexp } from 'next/dist/compiled/path-to-regexp'
87
import { escapeStringRegexp } from '../../escape-regexp'
98
import { parseUrl } from './parse-url'
109
import {
@@ -13,6 +12,10 @@ import {
1312
} from './interception-routes'
1413
import { getCookieParser } from '../../../../server/api-utils/get-cookie-parser'
1514
import type { Params } from '../../../../server/request/params'
15+
import {
16+
safePathToRegexp,
17+
safeCompile,
18+
} from '../../../../lib/try-to-parse-path'
1619

1720
/**
1821
* Ensure only a-zA-Z are used for param names for proper interpolating
@@ -156,7 +159,7 @@ export function compileNonPath(value: string, params: Params): string {
156159

157160
// the value needs to start with a forward-slash to be compiled
158161
// correctly
159-
return compile(`/${value}`, { validate: false })(params).slice(1)
162+
return safeCompile(`/${value}`, { validate: false })(params).slice(1)
160163
}
161164

162165
export function parseDestination(args: {
@@ -222,20 +225,20 @@ export function prepareDestination(args: {
222225
const destParams: (string | number)[] = []
223226

224227
const destPathParamKeys: Key[] = []
225-
pathToRegexp(destPath, destPathParamKeys)
228+
safePathToRegexp(destPath, destPathParamKeys)
226229
for (const key of destPathParamKeys) {
227230
destParams.push(key.name)
228231
}
229232

230233
if (destHostname) {
231234
const destHostnameParamKeys: Key[] = []
232-
pathToRegexp(destHostname, destHostnameParamKeys)
235+
safePathToRegexp(destHostname, destHostnameParamKeys)
233236
for (const key of destHostnameParamKeys) {
234237
destParams.push(key.name)
235238
}
236239
}
237240

238-
const destPathCompiler = compile(
241+
const destPathCompiler = safeCompile(
239242
destPath,
240243
// we don't validate while compiling the destination since we should
241244
// have already validated before we got to this point and validating
@@ -248,7 +251,7 @@ export function prepareDestination(args: {
248251

249252
let destHostnameCompiler
250253
if (destHostname) {
251-
destHostnameCompiler = compile(destHostname, { validate: false })
254+
destHostnameCompiler = safeCompile(destHostname, { validate: false })
252255
}
253256

254257
// update any params in query values

packages/next/src/shared/lib/router/utils/route-matcher.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Group } from './route-regex'
22
import { DecodeError } from '../../utils'
33
import type { Params } from '../../../../server/request/params'
4+
import { safeRouteMatcher } from '../../../../lib/try-to-parse-path'
45

56
export interface RouteMatchFn {
67
(pathname: string): false | Params
@@ -17,7 +18,7 @@ export function getRouteMatcher({
1718
re,
1819
groups,
1920
}: RouteMatcherOptions): RouteMatchFn {
20-
return (pathname: string) => {
21+
const rawMatcher = (pathname: string) => {
2122
const routeMatch = re.exec(pathname)
2223
if (!routeMatch) return false
2324

@@ -43,4 +44,7 @@ export function getRouteMatcher({
4344

4445
return params
4546
}
47+
48+
// Wrap with safe matcher to handle parameter cleaning
49+
return safeRouteMatcher(rawMatcher)
4650
}

packages/next/src/shared/lib/turbopack/manifest-loader.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
import type { BuildManifest } from '../../../server/get-page-files'
1313
import type { AppBuildManifest } from '../../../build/webpack/plugins/app-build-manifest-plugin'
1414
import type { PagesManifest } from '../../../build/webpack/plugins/pages-manifest-plugin'
15-
import { pathToRegexp } from 'next/dist/compiled/path-to-regexp'
1615
import type { ActionManifest } from '../../../build/webpack/plugins/flight-client-entry-plugin'
1716
import type { NextFontManifest } from '../../../build/webpack/plugins/next-font-manifest-plugin'
1817
import type { REACT_LOADABLE_MANIFEST } from '../constants'
@@ -52,7 +51,10 @@ import {
5251
addRouteSuffix,
5352
removeRouteSuffix,
5453
} from '../../../server/dev/turbopack-utils'
55-
import { tryToParsePath } from '../../../lib/try-to-parse-path'
54+
import {
55+
tryToParsePath,
56+
safePathToRegexp,
57+
} from '../../../lib/try-to-parse-path'
5658
import type { Entrypoints } from '../../../build/swc/types'
5759

5860
interface InstrumentationDefinition {
@@ -683,7 +685,7 @@ export class TurbopackManifestLoader {
683685
)) {
684686
for (const matcher of fun.matchers) {
685687
if (!matcher.regexp) {
686-
matcher.regexp = pathToRegexp(matcher.originalSource, [], {
688+
matcher.regexp = safePathToRegexp(matcher.originalSource, [], {
687689
delimiter: '/',
688690
sensitive: false,
689691
strict: true,

0 commit comments

Comments
 (0)