@@ -8,15 +8,22 @@ import {
8
8
RedirectInfo ,
9
9
SiteInfo ,
10
10
} from '@sitecore-jss/sitecore-jss/site' ;
11
- import { getPermutations } from '@sitecore-jss/sitecore-jss/utils' ;
11
+ import {
12
+ areURLSearchParamsEqual ,
13
+ escapeNonSpecialQuestionMarks ,
14
+ isRegexOrUrl ,
15
+ mergeURLSearchParams ,
16
+ } from '@sitecore-jss/sitecore-jss/utils' ;
17
+ import { NextURL } from 'next/dist/server/web/next-url' ;
12
18
import { NextRequest , NextResponse } from 'next/server' ;
13
19
import regexParser from 'regex-parser' ;
14
20
import { MiddlewareBase , MiddlewareBaseConfig } from './middleware' ;
15
- import { NextURL } from 'next/dist/server/web/next-url' ;
16
21
17
22
const REGEXP_CONTEXT_SITE_LANG = new RegExp ( / \$ s i t e L a n g / , 'i' ) ;
18
23
const REGEXP_ABSOLUTE_URL = new RegExp ( '^(?:[a-z]+:)?//' , 'i' ) ;
19
24
25
+ type RedirectResult = RedirectInfo & { matchedQueryString ?: string } ;
26
+
20
27
/**
21
28
* extended RedirectsMiddlewareConfig config type for RedirectsMiddleware
22
29
*/
@@ -78,15 +85,25 @@ export class RedirectsMiddleware extends MiddlewareBase {
78
85
} ) ;
79
86
80
87
const createResponse = async ( ) => {
88
+ const response = res || NextResponse . next ( ) ;
89
+
81
90
if ( this . config . disabled && this . config . disabled ( req , res || NextResponse . next ( ) ) ) {
82
91
debug . redirects ( 'skipped (redirects middleware is disabled)' ) ;
83
- return res || NextResponse . next ( ) ;
92
+ return response ;
84
93
}
85
94
86
95
if ( this . isPreview ( req ) || this . excludeRoute ( pathname ) ) {
87
96
debug . redirects ( 'skipped (%s)' , this . isPreview ( req ) ? 'preview' : 'route excluded' ) ;
88
97
89
- return res || NextResponse . next ( ) ;
98
+ return response ;
99
+ }
100
+
101
+ // Skip prefetch requests from Next.js, which are not original client requests
102
+ // as they load unnecessary requests that burden the redirects middleware with meaningless traffic
103
+ if ( this . isPrefetch ( req ) ) {
104
+ debug . redirects ( 'skipped (prefetch)' ) ;
105
+ response . headers . set ( 'x-middleware-cache' , 'no-cache' ) ;
106
+ return response ;
90
107
}
91
108
92
109
site = this . getSite ( req , res ) ;
@@ -97,7 +114,7 @@ export class RedirectsMiddleware extends MiddlewareBase {
97
114
if ( ! existsRedirect ) {
98
115
debug . redirects ( 'skipped (redirect does not exist)' ) ;
99
116
100
- return res || NextResponse . next ( ) ;
117
+ return response ;
101
118
}
102
119
103
120
// Find context site language and replace token
@@ -120,29 +137,37 @@ export class RedirectsMiddleware extends MiddlewareBase {
120
137
if ( REGEXP_ABSOLUTE_URL . test ( existsRedirect . target ) ) {
121
138
url . href = existsRedirect . target ;
122
139
} else {
123
- const source = `${ url . pathname . replace ( / \/ * $ / gi, '' ) } ${ existsRedirect . matchedQueryString } ` ;
124
- const urlFirstPart = existsRedirect . target . split ( '/' ) [ 1 ] ;
140
+ const isUrl = isRegexOrUrl ( existsRedirect . pattern ) === 'url' ;
141
+ const targetParts = existsRedirect . target . split ( '/' ) ;
142
+ const urlFirstPart = targetParts [ 1 ] ;
125
143
126
144
if ( this . locales . includes ( urlFirstPart ) ) {
127
145
req . nextUrl . locale = urlFirstPart ;
128
146
existsRedirect . target = existsRedirect . target . replace ( `/${ urlFirstPart } ` , '' ) ;
129
147
}
130
148
131
- const target = source
132
- . replace ( regexParser ( existsRedirect . pattern ) , existsRedirect . target )
133
- . replace ( / ^ \/ \/ / , '/' )
134
- . split ( '?' ) ;
135
-
136
- if ( url . search && existsRedirect . isQueryStringPreserved ) {
137
- const targetQueryString = target [ 1 ] ?? '' ;
138
- url . search = '?' + new URLSearchParams ( `${ url . search } &${ targetQueryString } ` ) . toString ( ) ;
139
- } else if ( target [ 1 ] ) {
140
- url . search = '?' + target [ 1 ] ;
141
- } else {
142
- url . search = '' ;
143
- }
144
-
145
- const prepareNewURL = new URL ( `${ target [ 0 ] } ${ url . search } ` , url . origin ) ;
149
+ const targetSegments = isUrl
150
+ ? existsRedirect . target . split ( '?' )
151
+ : url . pathname . replace ( / \/ * $ / gi, '' ) + existsRedirect . matchedQueryString ;
152
+
153
+ const [ targetPath , targetQueryString ] = isUrl
154
+ ? targetSegments
155
+ : ( targetSegments as string )
156
+ . replace ( regexParser ( existsRedirect . pattern ) , existsRedirect . target )
157
+ . replace ( / ^ \/ \/ / , '/' )
158
+ . split ( '?' ) ;
159
+
160
+ const mergedQueryString = existsRedirect . isQueryStringPreserved
161
+ ? mergeURLSearchParams (
162
+ new URLSearchParams ( url . search ?? '' ) ,
163
+ new URLSearchParams ( targetQueryString || '' )
164
+ )
165
+ : targetQueryString || '' ;
166
+
167
+ const prepareNewURL = new URL (
168
+ `${ targetPath } ${ mergedQueryString ? '?' + mergedQueryString : '' } ` ,
169
+ url . origin
170
+ ) ;
146
171
147
172
url . href = prepareNewURL . href ;
148
173
url . pathname = prepareNewURL . pathname ;
@@ -153,16 +178,16 @@ export class RedirectsMiddleware extends MiddlewareBase {
153
178
/** return Response redirect with http code of redirect type */
154
179
switch ( existsRedirect . redirectType ) {
155
180
case REDIRECT_TYPE_301 : {
156
- return this . createRedirectResponse ( url , res , 301 , 'Moved Permanently' ) ;
181
+ return this . createRedirectResponse ( url , response , 301 , 'Moved Permanently' ) ;
157
182
}
158
183
case REDIRECT_TYPE_302 : {
159
- return this . createRedirectResponse ( url , res , 302 , 'Found' ) ;
184
+ return this . createRedirectResponse ( url , response , 302 , 'Found' ) ;
160
185
}
161
186
case REDIRECT_TYPE_SERVER_TRANSFER : {
162
- return this . rewrite ( url . href , req , res || NextResponse . next ( ) ) ;
187
+ return this . rewrite ( url . href , req , response ) ;
163
188
}
164
189
default :
165
- return res || NextResponse . next ( ) ;
190
+ return response ;
166
191
}
167
192
} ;
168
193
@@ -188,20 +213,37 @@ export class RedirectsMiddleware extends MiddlewareBase {
188
213
private async getExistsRedirect (
189
214
req : NextRequest ,
190
215
siteName : string
191
- ) : Promise < ( RedirectInfo & { matchedQueryString ?: string } ) | undefined > {
192
- const redirects = await this . redirectsService . fetchRedirects ( siteName ) ;
216
+ ) : Promise < RedirectResult | undefined > {
193
217
const { pathname : targetURL , search : targetQS = '' , locale } = this . normalizeUrl (
194
218
req . nextUrl . clone ( )
195
219
) ;
220
+ const normalizedPath = targetURL . replace ( / \/ * $ / gi, '' ) ;
221
+ const redirects = await this . redirectsService . fetchRedirects ( siteName ) ;
196
222
const language = this . getLanguage ( req ) ;
197
223
const modifyRedirects = structuredClone ( redirects ) ;
224
+ let matchedQueryString : string | undefined ;
198
225
199
226
return modifyRedirects . length
200
- ? modifyRedirects . find ( ( redirect : RedirectInfo & { matchedQueryString ?: string } ) => {
227
+ ? modifyRedirects . find ( ( redirect : RedirectResult ) => {
228
+ if ( isRegexOrUrl ( redirect . pattern ) === 'url' ) {
229
+ const parseUrlPattern = redirect . pattern . endsWith ( '/' )
230
+ ? redirect . pattern . slice ( 0 , - 1 ) . split ( '?' )
231
+ : redirect . pattern . split ( '?' ) ;
232
+
233
+ return (
234
+ ( parseUrlPattern [ 0 ] === normalizedPath ||
235
+ parseUrlPattern [ 0 ] === `/${ locale } ${ normalizedPath } ` ) &&
236
+ areURLSearchParamsEqual (
237
+ new URLSearchParams ( parseUrlPattern [ 1 ] ?? '' ) ,
238
+ new URLSearchParams ( targetQS )
239
+ )
240
+ ) ;
241
+ }
242
+
201
243
// Modify the redirect pattern to ignore the language prefix in the path
202
244
// And escapes non-special "?" characters in a string or regex.
203
- redirect . pattern = this . escapeNonSpecialQuestionMarks (
204
- redirect . pattern . replace ( RegExp ( `^[^]?/${ language } /` , 'gi' ) , '' )
245
+ redirect . pattern = escapeNonSpecialQuestionMarks (
246
+ redirect . pattern . replace ( new RegExp ( `^[^]?/${ language } /` , 'gi' ) , '' )
205
247
) ;
206
248
207
249
// Prepare the redirect pattern as a regular expression, making it more flexible for matching URLs
@@ -211,47 +253,22 @@ export class RedirectsMiddleware extends MiddlewareBase {
211
253
. replace ( / ^ \^ | \$ $ / g, '' ) // Further cleans up anchors
212
254
. replace ( / \$ \/ g i $ / g, '' ) } [\/]?$/i`; // Ensures the pattern allows an optional trailing slash
213
255
214
- /**
215
- * This line checks whether the current URL query string (and all its possible permutations)
216
- * matches the redirect pattern.
217
- *
218
- * Query parameters in URLs can come in different orders, but logically they represent the
219
- * same information (e.g., "key1=value1&key2=value2" is the same as "key2=value2&key1=value1").
220
- * To account for this, the method `isPermutedQueryMatch` generates all possible permutations
221
- * of the query parameters and checks if any of those permutations match the regex pattern for the redirect.
222
- *
223
- * NOTE: This fix is specifically implemented for Netlify, where query parameters are sometimes
224
- * automatically sorted, which can cause issues with matching redirects if the order of query
225
- * parameters is important. By checking every possible permutation, we ensure that redirects
226
- * work correctly on Netlify despite this behavior.
227
- *
228
- * It passes several pieces of information to the function:
229
- * 1. `pathname`: The normalized URL path without query parameters (e.g., '/about').
230
- * 2. `queryString`: The current query string from the URL, which will be permuted and matched (e.g., '?key1=value1&key2=value2').
231
- * 3. `pattern`: The regex pattern for the redirect that we are trying to match against the URL (e.g., '/about?key1=value1').
232
- * 4. `locale`: The locale part of the URL (if any), which helps support multilingual URLs.
233
- *
234
- * If one of the permutations of the query string matches the redirect pattern, the function
235
- * returns the matched query string, which is stored in `matchedQueryString`. If no match is found,
236
- * it returns `undefined`. The `matchedQueryString` is later used to indicate whether the query
237
- * string contributed to a successful redirect match.
238
- */
239
- const matchedQueryString = this . isPermutedQueryMatch ( {
240
- pathname : targetURL ,
241
- queryString : targetQS ,
242
- pattern : redirect . pattern ,
243
- locale,
244
- } ) ;
256
+ matchedQueryString = [
257
+ regexParser ( redirect . pattern ) . test ( `${ normalizedPath } ${ targetQS } ` ) ,
258
+ regexParser ( redirect . pattern ) . test ( `/${ locale } ${ normalizedPath } ${ targetQS } ` ) ,
259
+ ] . some ( Boolean )
260
+ ? targetQS
261
+ : undefined ;
245
262
246
263
// Save the matched query string (if found) into the redirect object
247
264
redirect . matchedQueryString = matchedQueryString || '' ;
248
265
249
- // Return the redirect if the URL path or any query string permutation matches the pattern
250
266
return (
251
- ( regexParser ( redirect . pattern ) . test ( targetURL ) ||
267
+ ! ! (
268
+ regexParser ( redirect . pattern ) . test ( targetURL ) ||
252
269
regexParser ( redirect . pattern ) . test ( `/${ req . nextUrl . locale } ${ targetURL } ` ) ||
253
- matchedQueryString ) &&
254
- ( redirect . locale ? redirect . locale . toLowerCase ( ) === locale . toLowerCase ( ) : true )
270
+ matchedQueryString
271
+ ) && ( redirect . locale ? redirect . locale . toLowerCase ( ) === locale . toLowerCase ( ) : true )
255
272
) ;
256
273
} )
257
274
: undefined ;
@@ -328,87 +345,4 @@ export class RedirectsMiddleware extends MiddlewareBase {
328
345
}
329
346
return redirect ;
330
347
}
331
-
332
- /**
333
- * Checks if the current URL query matches the provided pattern, considering all permutations of query parameters.
334
- * It constructs all possible query parameter permutations and tests them against the pattern.
335
- * @param {object } params - The parameters for the URL match.
336
- * @param {string } params.pathname - The current URL pathname.
337
- * @param {string } params.queryString - The current URL query string.
338
- * @param {string } params.pattern - The regex pattern to test the constructed URLs against.
339
- * @param {string } [params.locale] - The locale prefix to include in the URL if present.
340
- * @returns {string | undefined } - return query string if any of the query permutations match the provided pattern, undefined otherwise.
341
- */
342
- private isPermutedQueryMatch ( {
343
- pathname,
344
- queryString,
345
- pattern,
346
- locale,
347
- } : {
348
- pathname : string ;
349
- queryString : string ;
350
- pattern : string ;
351
- locale ?: string ;
352
- } ) : string | undefined {
353
- const paramsArray = Array . from ( new URLSearchParams ( queryString ) . entries ( ) ) ;
354
- const listOfPermuted = getPermutations ( paramsArray ) . map (
355
- ( permutation : [ string , string ] [ ] ) =>
356
- '?' + permutation . map ( ( [ key , value ] ) => `${ key } =${ value } ` ) . join ( '&' )
357
- ) ;
358
-
359
- const normalizedPath = pathname . replace ( / \/ * $ / gi, '' ) ;
360
- return listOfPermuted . find ( ( query : string ) =>
361
- [
362
- regexParser ( pattern ) . test ( `${ normalizedPath } ${ query } ` ) ,
363
- regexParser ( pattern ) . test ( `/${ locale } ${ normalizedPath } ${ query } ` ) ,
364
- ] . some ( Boolean )
365
- ) ;
366
- }
367
-
368
- /**
369
- * Escapes non-special "?" characters in a string or regex.
370
- *
371
- * - For regular strings, it escapes all unescaped "?" characters by adding a backslash (`\`).
372
- * - For regex patterns (strings enclosed in `/.../`), it analyzes each "?" to determine if it has special meaning
373
- * (e.g., `?` in `(abc)?`, `.*?`) or is just a literal character. Only literal "?" characters are escaped.
374
- * @param {string } input - The input string or regex pattern.
375
- * @returns {string } - The modified string or regex with non-special "?" characters escaped.
376
- **/
377
- private escapeNonSpecialQuestionMarks ( input : string ) : string {
378
- const regexPattern = / (?< ! \\ ) \? / g; // Find unescaped "?" characters
379
- const isRegex = input . startsWith ( '/' ) && input . endsWith ( '/' ) ; // Check if the string is a regex
380
-
381
- if ( ! isRegex ) {
382
- // If not a regex, escape all unescaped "?" characters
383
- return input . replace ( regexPattern , '\\?' ) ;
384
- }
385
-
386
- // If it's a regex, analyze each "?" character
387
- let result = '' ;
388
- let lastIndex = 0 ;
389
-
390
- let match ;
391
- while ( ( match = regexPattern . exec ( input ) ) !== null ) {
392
- const index = match . index ; // Position of "?" in the string
393
- const before = input . slice ( 0 , index ) . replace ( / \s + $ / , '' ) ; // Context before "?"
394
- const lastChar = before . slice ( - 1 ) ; // Last character before "?"
395
-
396
- // Determine if the "?" is a special regex symbol
397
- const isSpecialRegexSymbol = / [ \. \* \+ \) \[ \] ] $ / . test ( lastChar ) ;
398
-
399
- if ( isSpecialRegexSymbol ) {
400
- // If it's special, keep it as is
401
- result += input . slice ( lastIndex , index + 1 ) ;
402
- } else {
403
- // If it's not special, escape it
404
- result += input . slice ( lastIndex , index ) + '\\?' ;
405
- }
406
- lastIndex = index + 1 ;
407
- }
408
-
409
- // Append the remaining part of the string
410
- result += input . slice ( lastIndex ) ;
411
-
412
- return result ;
413
- }
414
348
}
0 commit comments