Skip to content

Commit add7853

Browse files
sc-ruslanmatkovskyiRuslan Matkovskyi
and
Ruslan Matkovskyi
authored
[sitecore-jss-nextjs] Improve performance for redirects (#2003)
* [sitecore-jss-nextjs]: The cache of results of pattern has been added * CHANGELOG has been changed * [sitecore-jss-nextjs]: Removed a method that addressed Netlify issues. Added a simpler solution for non-regex patterns to fix Netlify-related problems. Improved redirect performance. --------- Co-authored-by: Ruslan Matkovskyi <[email protected]>
1 parent 8f30b87 commit add7853

File tree

6 files changed

+306
-201
lines changed

6 files changed

+306
-201
lines changed

CHANGELOG.md

+1-3
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,8 @@ Our versioning strategy is as follows:
1818
### 🐛 Bug Fixes
1919

2020
* `[templates/nextjs-sxa]` Fixed font-awesome import issue in custom workspace configuration ([#1998](https://github.com/Sitecore/jss/pull/1998))
21-
22-
### 🐛 Bug Fixes
23-
2421
* `[sitecore-jss-nextjs]` Fixed handling of ? inside square brackets [] in regex patterns to prevent incorrect escaping ([#1999](https://github.com/Sitecore/jss/pull/1999))
22+
* `[sitecore-jss-nextjs]` Improve performance for redirect middleware ([#2003](https://github.com/Sitecore/jss/pull/2003))
2523

2624
## 22.3.0 / 22.3.1
2725

packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ describe('RedirectsMiddleware', () => {
822822
request: {
823823
nextUrl: {
824824
pathname: '/not-found',
825-
search: 'abc=def',
825+
search: '',
826826
href: 'http://localhost:3000/not-found',
827827
locale: 'en',
828828
origin: 'http://localhost:3000',
@@ -1361,7 +1361,7 @@ describe('RedirectsMiddleware', () => {
13611361
expect(finalRes.status).to.equal(res.status);
13621362
});
13631363

1364-
it('should return 301 redirect when queryString is ordered by alphabetic(Netlify feature)', async () => {
1364+
it('should return 301 redirect when pattern has special symbols "?"', async () => {
13651365
const cloneUrl = () => Object.assign({}, req.nextUrl);
13661366
const url = {
13671367
clone: cloneUrl,
@@ -1389,7 +1389,7 @@ describe('RedirectsMiddleware', () => {
13891389

13901390
const { finalRes, fetchRedirects, siteResolver } = await runTestWithRedirect(
13911391
{
1392-
pattern: '/not-found?w=1&a=1',
1392+
pattern: '/[/]?not-found?a=1&w=1/',
13931393
target: '/found',
13941394
redirectType: REDIRECT_TYPE_301,
13951395
isQueryStringPreserved: true,
@@ -1412,7 +1412,7 @@ describe('RedirectsMiddleware', () => {
14121412
expect(finalRes.status).to.equal(res.status);
14131413
});
14141414

1415-
it('should return 301 redirect when pattern has special symbols "?"', async () => {
1415+
it('should return 301 redirect when pattern has another order of query string', async () => {
14161416
const cloneUrl = () => Object.assign({}, req.nextUrl);
14171417
const url = {
14181418
clone: cloneUrl,
@@ -1440,7 +1440,7 @@ describe('RedirectsMiddleware', () => {
14401440

14411441
const { finalRes, fetchRedirects, siteResolver } = await runTestWithRedirect(
14421442
{
1443-
pattern: '/[/]?not-found?a=1&w=1/',
1443+
pattern: '/not-found?w=1&a=1/',
14441444
target: '/found',
14451445
redirectType: REDIRECT_TYPE_301,
14461446
isQueryStringPreserved: true,

packages/sitecore-jss-nextjs/src/middleware/redirects-middleware.ts

+83-149
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,22 @@ import {
88
RedirectInfo,
99
SiteInfo,
1010
} 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';
1218
import { NextRequest, NextResponse } from 'next/server';
1319
import regexParser from 'regex-parser';
1420
import { MiddlewareBase, MiddlewareBaseConfig } from './middleware';
15-
import { NextURL } from 'next/dist/server/web/next-url';
1621

1722
const REGEXP_CONTEXT_SITE_LANG = new RegExp(/\$siteLang/, 'i');
1823
const REGEXP_ABSOLUTE_URL = new RegExp('^(?:[a-z]+:)?//', 'i');
1924

25+
type RedirectResult = RedirectInfo & { matchedQueryString?: string };
26+
2027
/**
2128
* extended RedirectsMiddlewareConfig config type for RedirectsMiddleware
2229
*/
@@ -78,15 +85,25 @@ export class RedirectsMiddleware extends MiddlewareBase {
7885
});
7986

8087
const createResponse = async () => {
88+
const response = res || NextResponse.next();
89+
8190
if (this.config.disabled && this.config.disabled(req, res || NextResponse.next())) {
8291
debug.redirects('skipped (redirects middleware is disabled)');
83-
return res || NextResponse.next();
92+
return response;
8493
}
8594

8695
if (this.isPreview(req) || this.excludeRoute(pathname)) {
8796
debug.redirects('skipped (%s)', this.isPreview(req) ? 'preview' : 'route excluded');
8897

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;
90107
}
91108

92109
site = this.getSite(req, res);
@@ -97,7 +114,7 @@ export class RedirectsMiddleware extends MiddlewareBase {
97114
if (!existsRedirect) {
98115
debug.redirects('skipped (redirect does not exist)');
99116

100-
return res || NextResponse.next();
117+
return response;
101118
}
102119

103120
// Find context site language and replace token
@@ -120,29 +137,37 @@ export class RedirectsMiddleware extends MiddlewareBase {
120137
if (REGEXP_ABSOLUTE_URL.test(existsRedirect.target)) {
121138
url.href = existsRedirect.target;
122139
} 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];
125143

126144
if (this.locales.includes(urlFirstPart)) {
127145
req.nextUrl.locale = urlFirstPart;
128146
existsRedirect.target = existsRedirect.target.replace(`/${urlFirstPart}`, '');
129147
}
130148

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+
);
146171

147172
url.href = prepareNewURL.href;
148173
url.pathname = prepareNewURL.pathname;
@@ -153,16 +178,16 @@ export class RedirectsMiddleware extends MiddlewareBase {
153178
/** return Response redirect with http code of redirect type */
154179
switch (existsRedirect.redirectType) {
155180
case REDIRECT_TYPE_301: {
156-
return this.createRedirectResponse(url, res, 301, 'Moved Permanently');
181+
return this.createRedirectResponse(url, response, 301, 'Moved Permanently');
157182
}
158183
case REDIRECT_TYPE_302: {
159-
return this.createRedirectResponse(url, res, 302, 'Found');
184+
return this.createRedirectResponse(url, response, 302, 'Found');
160185
}
161186
case REDIRECT_TYPE_SERVER_TRANSFER: {
162-
return this.rewrite(url.href, req, res || NextResponse.next());
187+
return this.rewrite(url.href, req, response);
163188
}
164189
default:
165-
return res || NextResponse.next();
190+
return response;
166191
}
167192
};
168193

@@ -188,20 +213,37 @@ export class RedirectsMiddleware extends MiddlewareBase {
188213
private async getExistsRedirect(
189214
req: NextRequest,
190215
siteName: string
191-
): Promise<(RedirectInfo & { matchedQueryString?: string }) | undefined> {
192-
const redirects = await this.redirectsService.fetchRedirects(siteName);
216+
): Promise<RedirectResult | undefined> {
193217
const { pathname: targetURL, search: targetQS = '', locale } = this.normalizeUrl(
194218
req.nextUrl.clone()
195219
);
220+
const normalizedPath = targetURL.replace(/\/*$/gi, '');
221+
const redirects = await this.redirectsService.fetchRedirects(siteName);
196222
const language = this.getLanguage(req);
197223
const modifyRedirects = structuredClone(redirects);
224+
let matchedQueryString: string | undefined;
198225

199226
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+
201243
// Modify the redirect pattern to ignore the language prefix in the path
202244
// 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'), '')
205247
);
206248

207249
// Prepare the redirect pattern as a regular expression, making it more flexible for matching URLs
@@ -211,47 +253,22 @@ export class RedirectsMiddleware extends MiddlewareBase {
211253
.replace(/^\^|\$$/g, '') // Further cleans up anchors
212254
.replace(/\$\/gi$/g, '')}[\/]?$/i`; // Ensures the pattern allows an optional trailing slash
213255

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;
245262

246263
// Save the matched query string (if found) into the redirect object
247264
redirect.matchedQueryString = matchedQueryString || '';
248265

249-
// Return the redirect if the URL path or any query string permutation matches the pattern
250266
return (
251-
(regexParser(redirect.pattern).test(targetURL) ||
267+
!!(
268+
regexParser(redirect.pattern).test(targetURL) ||
252269
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)
255272
);
256273
})
257274
: undefined;
@@ -328,87 +345,4 @@ export class RedirectsMiddleware extends MiddlewareBase {
328345
}
329346
return redirect;
330347
}
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-
}
414348
}

packages/sitecore-jss/src/utils/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ export {
44
isAbsoluteUrl,
55
isTimeoutError,
66
enforceCors,
7-
getPermutations,
87
EnhancedOmit,
98
getAllowedOriginsFromEnv,
9+
isRegexOrUrl,
10+
areURLSearchParamsEqual,
11+
escapeNonSpecialQuestionMarks,
12+
mergeURLSearchParams,
1013
} from './utils';
1114
export { tryParseEnvValue } from './env';
1215
// @deprecated - import editing utils from 'editing' submodule instead. Will be removed in a future major release.

0 commit comments

Comments
 (0)