From e65c56e7e6c16c3c971984ce7acd161bca6b9c2f Mon Sep 17 00:00:00 2001 From: Javi Velasco Date: Wed, 21 Jul 2021 18:12:33 +0200 Subject: [PATCH] Refactor i18n checks on request handling (#27328) Currently there is a lot of mutation in the Next.js Server and the checks for Locale are directly coded in the general request handler. Ideally, we should have a function where we just pass the request input (url + headers + config) and generate a bunch of metadata that analyzes it generating all metadata we might require for both the URL and i18n + basePath information. This PR brings: - A new parsing function `parseUrl` that joins parsing an absolute/relative URL into a data structure compatible with the Node parsing output but missing redundant properties. - A wrapper `parseNextURL` that extends `parseUrl` analyzing `i18n` and `basePath` based on the provided configuration, url and headers. This function is pure and stateless so it can be used outside of the Next.js context. - Types improvements and reuse. - Refactors `next-server.ts` request handling using the above mentioned functions so that the code there just apply effects to the `req` object and the `parsedUrl.query` leaving the code much more straightforward. - Refactors `getRouteRegex` decomposing in two different functions where `getParametrizedRoute` can be used to retrieve the serializable data that is used to generate the Regex. --- .../next-serverless-loader/page-handler.ts | 2 +- packages/next/server/api-utils.ts | 14 +- packages/next/server/config-shared.ts | 28 +-- packages/next/server/config.ts | 2 +- packages/next/server/next-server.ts | 186 ++++-------------- packages/next/server/render.tsx | 8 +- .../shared/lib/i18n/detect-domain-locale.ts | 20 +- .../shared/lib/i18n/get-locale-metadata.ts | 116 +++++++++++ .../shared/lib/i18n/normalize-locale-path.ts | 19 +- packages/next/shared/lib/router/router.ts | 10 +- .../shared/lib/router/utils/parse-next-url.ts | 53 +++++ .../next/shared/lib/router/utils/parse-url.ts | 32 +++ .../lib/router/utils/prepare-destination.ts | 43 +--- .../shared/lib/router/utils/route-regex.ts | 45 +++-- packages/next/shared/lib/utils.ts | 20 +- 15 files changed, 331 insertions(+), 267 deletions(-) create mode 100644 packages/next/shared/lib/i18n/get-locale-metadata.ts create mode 100644 packages/next/shared/lib/router/utils/parse-next-url.ts create mode 100644 packages/next/shared/lib/router/utils/parse-url.ts diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts index 1c9e97f77d952..cddafae1d2f1e 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts @@ -98,7 +98,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { let hasValidParams = true - setLazyProp({ req: req as any }, 'cookies', getCookieParser(req)) + setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers)) const options = { App, diff --git a/packages/next/server/api-utils.ts b/packages/next/server/api-utils.ts index e172995568f94..2c19813906f46 100644 --- a/packages/next/server/api-utils.ts +++ b/packages/next/server/api-utils.ts @@ -41,7 +41,7 @@ export async function apiResolver( const externalResolver = config.api?.externalResolver || false // Parsing of cookies - setLazyProp({ req: apiReq }, 'cookies', getCookieParser(req)) + setLazyProp({ req: apiReq }, 'cookies', getCookieParser(req.headers)) // Parsing query string apiReq.query = query // Parsing preview data @@ -185,14 +185,14 @@ function parseJson(str: string): object { } /** - * Parse cookies from `req` header + * Parse cookies from the `headers` of request * @param req request object */ -export function getCookieParser( - req: IncomingMessage -): () => NextApiRequestCookies { +export function getCookieParser(headers: { + [key: string]: undefined | string | string[] +}): () => NextApiRequestCookies { return function parseCookie(): NextApiRequestCookies { - const header: undefined | string | string[] = req.headers.cookie + const header: undefined | string | string[] = headers.cookie if (!header) { return {} @@ -321,7 +321,7 @@ export function tryGetPreviewData( return (req as any)[SYMBOL_PREVIEW_DATA] as any } - const getCookies = getCookieParser(req) + const getCookies = getCookieParser(req.headers) let cookies: NextApiRequestCookies try { cookies = getCookies() diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 22170d28bb40e..395d5edc4fef9 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -2,26 +2,28 @@ import os from 'os' import { Header, Redirect, Rewrite } from '../lib/load-custom-routes' import { ImageConfig, imageConfigDefault } from './image-config' -export type DomainLocales = Array<{ - http?: true - domain: string - locales?: string[] - defaultLocale: string -}> - type NoOptionals = { [P in keyof T]-?: T[P] } export type NextConfigComplete = NoOptionals +export interface I18NConfig { + defaultLocale: string + domains?: DomainLocale[] + localeDetection?: false + locales: string[] +} + +export interface DomainLocale { + defaultLocale: string + domain: string + http?: true + locales?: string[] +} + export type NextConfig = { [key: string]: any } & { - i18n?: { - locales: string[] - defaultLocale: string - domains?: DomainLocales - localeDetection?: false - } | null + i18n?: I18NConfig | null headers?: () => Promise rewrites?: () => Promise< diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 9772635518865..7aca7993772b0 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -14,7 +14,7 @@ import { loadWebpackHook } from './config-utils' import { ImageConfig, imageConfigDefault, VALID_LOADERS } from './image-config' import { loadEnvConfig } from '@next/env' -export { DomainLocales, NextConfig, normalizeConfig } from './config-shared' +export { DomainLocale, NextConfig, normalizeConfig } from './config-shared' const targets = ['server', 'serverless', 'experimental-serverless-trace'] diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 62691f8a6a20a..9347e06b70ce0 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -54,7 +54,7 @@ import { tryGetPreviewData, __ApiPreviewProps, } from './api-utils' -import { DomainLocales, isTargetLikeServerless, NextConfig } from './config' +import { DomainLocale, isTargetLikeServerless, NextConfig } from './config' import pathMatch from '../shared/lib/router/utils/path-match' import { recursiveReadDirSync } from './lib/recursive-readdir-sync' import { loadComponents, LoadComponentsReturnType } from './load-components' @@ -83,13 +83,10 @@ import { removePathTrailingSlash } from '../client/normalize-trailing-slash' import getRouteFromAssetPath from '../shared/lib/router/utils/get-route-from-asset-path' import { FontManifest } from './font-utils' import { denormalizePagePath } from './denormalize-page-path' -import accept from '@hapi/accept' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' -import { detectLocaleCookie } from '../shared/lib/i18n/detect-locale-cookie' import * as Log from '../build/output/log' import { imageOptimizer } from './image-optimizer' import { detectDomainLocale } from '../shared/lib/i18n/detect-domain-locale' -import cookie from 'next/dist/compiled/cookie' import escapePathDelimiters from '../shared/lib/router/utils/escape-path-delimiters' import { getUtils } from '../build/webpack/loaders/next-serverless-loader/utils' import { PreviewData } from 'next/types' @@ -98,6 +95,7 @@ import ResponseCache, { ResponseCacheValue, } from './response-cache' import { NextConfigComplete } from './config-shared' +import { parseNextUrl } from '../shared/lib/router/utils/parse-next-url' const getCustomRouteMatcher = pathMatch(true) @@ -175,7 +173,7 @@ export default class Server { locale?: string locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] distDir: string } private compression?: Middleware @@ -309,7 +307,7 @@ export default class Server { res: ServerResponse, parsedUrl?: UrlWithParsedQuery ): Promise { - setLazyProp({ req: req as any }, 'cookies', getCookieParser(req)) + setLazyProp({ req: req as any }, 'cookies', getCookieParser(req.headers)) // Parse url if parsedUrl not provided if (!parsedUrl || typeof parsedUrl !== 'object') { @@ -324,9 +322,13 @@ export default class Server { } ;(req as any).__NEXT_INIT_QUERY = Object.assign({}, parsedUrl.query) - if (basePath && req.url?.startsWith(basePath)) { - // store original URL to allow checking if basePath was - // provided or not + const url = parseNextUrl({ + headers: req.headers, + nextConfig: this.nextConfig, + url: req.url?.replace(/^\/+/, '/'), + }) + + if (url.basePath) { ;(req as any)._nextHadBasePath = true req.url = req.url!.replace(basePath, '') || '/' } @@ -436,156 +438,34 @@ export default class Server { }` } - if (i18n) { - // get pathname from URL with basePath stripped for locale detection - let { pathname, ...parsed } = parseUrl(req.url || '/') - pathname = pathname || '/' - - let defaultLocale = i18n.defaultLocale - let detectedLocale = detectLocaleCookie(req, i18n.locales) - let acceptPreferredLocale - try { - acceptPreferredLocale = - i18n.localeDetection !== false - ? accept.language(req.headers['accept-language'], i18n.locales) - : detectedLocale - } catch (_) { - acceptPreferredLocale = detectedLocale - } - const { host } = req?.headers || {} - // remove port from host if present - const hostname = host?.split(':')[0].toLowerCase() - - const detectedDomain = detectDomainLocale(i18n.domains, hostname) - if (detectedDomain) { - defaultLocale = detectedDomain.defaultLocale - detectedLocale = defaultLocale - ;(req as any).__nextIsLocaleDomain = true - } - - // if not domain specific locale use accept-language preferred - detectedLocale = detectedLocale || acceptPreferredLocale - - let localeDomainRedirect: string | undefined - ;(req as any).__nextHadTrailingSlash = pathname!.endsWith('/') - - if (pathname === '/') { - ;(req as any).__nextHadTrailingSlash = this.nextConfig.trailingSlash - } - const localePathResult = normalizeLocalePath(pathname!, i18n.locales) - - if (localePathResult.detectedLocale) { - detectedLocale = localePathResult.detectedLocale - req.url = formatUrl({ - ...parsed, - pathname: localePathResult.pathname, - }) - ;(req as any).__nextStrippedLocale = true - - if ( - localePathResult.pathname === '/api' || - localePathResult.pathname.startsWith('/api/') - ) { - return this.render404(req, res, parsedUrl) - } - } - - // If a detected locale is a domain specific locale and we aren't already - // on that domain and path prefix redirect to it to prevent duplicate - // content from multiple domains - if (detectedDomain && pathname === '/') { - const localeToCheck = acceptPreferredLocale - // const localeToCheck = localePathResult.detectedLocale - // ? detectedLocale - // : acceptPreferredLocale - - const matchedDomain = detectDomainLocale( - i18n.domains, - undefined, - localeToCheck - ) + ;(req as any).__nextHadTrailingSlash = url.locale?.trailingSlash + if (url.locale?.domain) { + ;(req as any).__nextIsLocaleDomain = true + } - if ( - matchedDomain && - (matchedDomain.domain !== detectedDomain.domain || - localeToCheck !== matchedDomain.defaultLocale) - ) { - localeDomainRedirect = `http${matchedDomain.http ? '' : 's'}://${ - matchedDomain.domain - }/${ - localeToCheck === matchedDomain.defaultLocale ? '' : localeToCheck - }` - } + if (url.locale?.path.detectedLocale) { + req.url = formatUrl(url) + ;(req as any).__nextStrippedLocale = true + if (url.pathname === '/api' || url.pathname.startsWith('/api/')) { + return this.render404(req, res, parsedUrl) } + } - const denormalizedPagePath = denormalizePagePath(pathname || '/') - const detectedDefaultLocale = - !detectedLocale || - detectedLocale.toLowerCase() === defaultLocale.toLowerCase() - const shouldStripDefaultLocale = false - // detectedDefaultLocale && - // denormalizedPagePath.toLowerCase() === - // `/${i18n.defaultLocale.toLowerCase()}` - - const shouldAddLocalePrefix = - !detectedDefaultLocale && denormalizedPagePath === '/' - - detectedLocale = detectedLocale || i18n.defaultLocale - - if ( - i18n.localeDetection !== false && - (localeDomainRedirect || - shouldAddLocalePrefix || - shouldStripDefaultLocale) - ) { - // set the NEXT_LOCALE cookie when a user visits the default locale - // with the locale prefix so that they aren't redirected back to - // their accept-language preferred locale - if ( - shouldStripDefaultLocale && - acceptPreferredLocale !== defaultLocale - ) { - const previous = res.getHeader('set-cookie') - - res.setHeader('set-cookie', [ - ...(typeof previous === 'string' - ? [previous] - : Array.isArray(previous) - ? previous - : []), - cookie.serialize('NEXT_LOCALE', defaultLocale, { - httpOnly: true, - path: '/', - }), - ]) - } - - res.setHeader( - 'Location', - localeDomainRedirect - ? localeDomainRedirect - : formatUrl({ - // make sure to include any query values when redirecting - ...parsed, - pathname: shouldStripDefaultLocale - ? basePath || `/` - : `${basePath || ''}/${detectedLocale}`, - }) - ) - res.statusCode = TEMPORARY_REDIRECT_STATUS - res.end() - return + if (!this.minimalMode || !parsedUrl.query.__nextLocale) { + if (url?.locale?.locale) { + parsedUrl.query.__nextLocale = url.locale.locale } + } - parsedUrl.query.__nextDefaultLocale = - detectedDomain?.defaultLocale || i18n.defaultLocale + if (url?.locale?.defaultLocale) { + parsedUrl.query.__nextDefaultLocale = url.locale.defaultLocale + } - if (!this.minimalMode || !parsedUrl.query.__nextLocale) { - parsedUrl.query.__nextLocale = - localePathResult.detectedLocale || - detectedDomain?.defaultLocale || - defaultLocale - } + if (url.locale?.redirect) { + res.setHeader('Location', url.locale.redirect) + res.statusCode = TEMPORARY_REDIRECT_STATUS + res.end() + return } res.statusCode = 200 diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index 8e9dff5a6d0e7..b9ebabfd056ae 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -59,7 +59,7 @@ import { getRedirectStatus, Redirect, } from '../lib/load-custom-routes' -import { DomainLocales } from './config' +import type { DomainLocale } from './config' function noRouter() { const message = @@ -79,7 +79,7 @@ class ServerRouter implements NextRouter { isReady: boolean locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] isPreview: boolean isLocaleDomain: boolean @@ -93,7 +93,7 @@ class ServerRouter implements NextRouter { locale?: string, locales?: string[], defaultLocale?: string, - domainLocales?: DomainLocales, + domainLocales?: DomainLocale[], isPreview?: boolean, isLocaleDomain?: boolean ) { @@ -186,7 +186,7 @@ export type RenderOptsPartial = { locale?: string locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] disableOptimizedLoading?: boolean requireStaticHTML?: boolean } diff --git a/packages/next/shared/lib/i18n/detect-domain-locale.ts b/packages/next/shared/lib/i18n/detect-domain-locale.ts index 7ad9d9890e57f..0c8b9167f3ee1 100644 --- a/packages/next/shared/lib/i18n/detect-domain-locale.ts +++ b/packages/next/shared/lib/i18n/detect-domain-locale.ts @@ -1,23 +1,11 @@ +import type { DomainLocale } from '../../../server/config-shared' + export function detectDomainLocale( - domainItems: - | Array<{ - http?: boolean - domain: string - locales?: string[] - defaultLocale: string - }> - | undefined, + domainItems?: DomainLocale[], hostname?: string, detectedLocale?: string ) { - let domainItem: - | { - http?: boolean - domain: string - locales?: string[] - defaultLocale: string - } - | undefined + let domainItem: DomainLocale | undefined if (domainItems) { if (detectedLocale) { diff --git a/packages/next/shared/lib/i18n/get-locale-metadata.ts b/packages/next/shared/lib/i18n/get-locale-metadata.ts new file mode 100644 index 0000000000000..bf9caa43307c8 --- /dev/null +++ b/packages/next/shared/lib/i18n/get-locale-metadata.ts @@ -0,0 +1,116 @@ +import accept from '@hapi/accept' +import { denormalizePagePath } from '../../../server/denormalize-page-path' +import { detectDomainLocale } from './detect-domain-locale' +import { formatUrl } from '../router/utils/format-url' +import { normalizeLocalePath } from './normalize-locale-path' +import type { I18NConfig, DomainLocale } from '../../../server/config-shared' + +interface Params { + cookies(): { [key: string]: string } + headers?: { [key: string]: string | string[] | undefined } + nextConfig: { basePath?: string; i18n: I18NConfig; trailingSlash?: boolean } + url: { hostname?: string | null; pathname: string } +} + +export function getLocaleMetadata(params: Params) { + const { i18n } = params.nextConfig + const { cookies, headers, nextConfig, url } = params + const path = normalizeLocalePath(url.pathname, i18n.locales) + const domain = detectDomainLocale(i18n.domains, getHostname(url, headers)) + const defaultLocale = domain?.defaultLocale || i18n.defaultLocale + const preferredLocale = getAcceptPreferredLocale(i18n, headers) + return { + path, + domain, + defaultLocale, + locale: path?.detectedLocale || defaultLocale, + redirect: getRedirect({ + locale: { + preferred: preferredLocale, + default: defaultLocale, + detected: + path?.detectedLocale || + domain?.defaultLocale || + getLocaleFromCookie(i18n, cookies) || + preferredLocale || + i18n.defaultLocale, + }, + domain, + nextConfig, + url, + }), + trailingSlash: + url.pathname !== '/' + ? url.pathname.endsWith('/') + : nextConfig.trailingSlash, + } +} + +function getLocaleFromCookie( + i18n: I18NConfig, + cookies: () => { [key: string]: string } +) { + const nextLocale = cookies()?.NEXT_LOCALE?.toLowerCase() + return nextLocale + ? i18n.locales.find((locale) => nextLocale === locale.toLowerCase()) + : undefined +} + +function getAcceptPreferredLocale( + i18n: I18NConfig, + headers?: { [key: string]: string | string[] | undefined } +) { + const value = headers?.['accept-language'] + if (i18n.localeDetection !== false && value && !Array.isArray(value)) { + try { + return accept.language(value, i18n.locales) + } catch (err) {} + } +} + +function getHostname( + parsed: { hostname?: string | null }, + headers?: { [key: string]: string | string[] | undefined } +) { + return ((!Array.isArray(headers?.host) && headers?.host) || parsed.hostname) + ?.split(':')[0] + .toLowerCase() +} + +function getRedirect({ + domain, + locale, + nextConfig, + url, +}: { + domain?: DomainLocale + locale: { default: string; detected: string; preferred?: string } + nextConfig: { basePath?: string; i18n: I18NConfig; trailingSlash?: boolean } + url: { hostname?: string | null; pathname: string } +}) { + const isRootPath = denormalizePagePath(url.pathname) === '/' + if (nextConfig.i18n.localeDetection !== false && isRootPath) { + const preferredDomain = detectDomainLocale( + nextConfig.i18n.domains, + undefined, + locale.preferred + ) + + if (domain && preferredDomain) { + const isPDomain = preferredDomain.domain === domain.domain + const isPLocale = preferredDomain.defaultLocale === locale.preferred + if (!isPDomain || !isPLocale) { + const scheme = `http${preferredDomain.http ? '' : 's'}` + const rlocale = isPLocale ? '' : locale.preferred + return `${scheme}://${preferredDomain.domain}/${rlocale}` + } + } + + if (locale.detected.toLowerCase() !== locale.default.toLowerCase()) { + return formatUrl({ + ...url, + pathname: `${nextConfig.basePath || ''}/${locale.detected}`, + }) + } + } +} diff --git a/packages/next/shared/lib/i18n/normalize-locale-path.ts b/packages/next/shared/lib/i18n/normalize-locale-path.ts index 60298ca20f4d9..ee21339dfc598 100644 --- a/packages/next/shared/lib/i18n/normalize-locale-path.ts +++ b/packages/next/shared/lib/i18n/normalize-locale-path.ts @@ -1,10 +1,21 @@ +export interface PathLocale { + detectedLocale?: string + pathname: string +} + +/** + * For a pathname that may include a locale from a list of locales, it + * removes the locale from the pathname returning it alongside with the + * detected locale. + * + * @param pathname A pathname that may include a locale. + * @param locales A list of locales. + * @returns The detected locale and pathname without locale + */ export function normalizeLocalePath( pathname: string, locales?: string[] -): { - detectedLocale?: string - pathname: string -} { +): PathLocale { let detectedLocale: string | undefined // first item will be empty string from splitting at first char const pathnameParts = pathname.split('/') diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index e403f3239a0ba..8f8bc803e2eb7 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -13,7 +13,7 @@ import { markAssetError, } from '../../../client/route-loader' import { RouterEvent } from '../../../client/router' -import { DomainLocales } from '../../../server/config' +import type { DomainLocale } from '../../../server/config' import { denormalizePagePath } from '../../../server/denormalize-page-path' import { normalizeLocalePath } from '../i18n/normalize-locale-path' import mitt, { MittEmitter } from '../mitt' @@ -89,7 +89,7 @@ export function getDomainLocale( path: string, locale?: string | false, locales?: string[], - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] ) { if (process.env.__NEXT_I18N_SUPPORT) { locale = locale || normalizeLocalePath(path, locales).detectedLocale @@ -388,7 +388,7 @@ export type BaseRouter = { locale?: string locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] isLocaleDomain: boolean } @@ -532,7 +532,7 @@ export default class Router implements BaseRouter { locale?: string locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] isReady: boolean isPreview: boolean isLocaleDomain: boolean @@ -571,7 +571,7 @@ export default class Router implements BaseRouter { locale?: string locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] isPreview?: boolean } ) { diff --git a/packages/next/shared/lib/router/utils/parse-next-url.ts b/packages/next/shared/lib/router/utils/parse-next-url.ts new file mode 100644 index 0000000000000..1b86fb113236e --- /dev/null +++ b/packages/next/shared/lib/router/utils/parse-next-url.ts @@ -0,0 +1,53 @@ +import { getCookieParser } from '../../../../server/api-utils' +import { getLocaleMetadata } from '../../i18n/get-locale-metadata' +import { parseUrl } from './parse-url' +import type { NextConfig, DomainLocale } from '../../../../server/config-shared' +import type { ParsedUrl } from './parse-url' +import type { PathLocale } from '../../i18n/normalize-locale-path' + +interface Params { + headers?: { [key: string]: string | string[] | undefined } + nextConfig: NextConfig + url?: string +} + +export function parseNextUrl({ headers, nextConfig, url = '/' }: Params) { + const urlParsed: ParsedNextUrl = parseUrl(url) + const { basePath } = nextConfig + + if (basePath && urlParsed.pathname.startsWith(basePath)) { + urlParsed.pathname = urlParsed.pathname.replace(basePath, '') || '/' + urlParsed.basePath = basePath + } + + if (nextConfig.i18n) { + urlParsed.locale = getLocaleMetadata({ + cookies: getCookieParser(headers || {}), + headers: headers, + nextConfig: { + basePath: nextConfig.basePath, + i18n: nextConfig.i18n, + trailingSlash: nextConfig.trailingSlash, + }, + url: urlParsed, + }) + + if (urlParsed.locale?.path.detectedLocale) { + urlParsed.pathname = urlParsed.locale.path.pathname + } + } + + return urlParsed +} + +export interface ParsedNextUrl extends ParsedUrl { + basePath?: string + locale?: { + defaultLocale: string + domain?: DomainLocale + locale: string + path: PathLocale + redirect?: string + trailingSlash?: boolean + } +} diff --git a/packages/next/shared/lib/router/utils/parse-url.ts b/packages/next/shared/lib/router/utils/parse-url.ts new file mode 100644 index 0000000000000..866014f88f947 --- /dev/null +++ b/packages/next/shared/lib/router/utils/parse-url.ts @@ -0,0 +1,32 @@ +import type { ParsedUrlQuery } from 'querystring' +import { searchParamsToUrlQuery } from './querystring' +import { parseRelativeUrl } from './parse-relative-url' + +export interface ParsedUrl { + hash: string + hostname?: string | null + href: string + pathname: string + port?: string | null + protocol?: string | null + query: ParsedUrlQuery + search: string +} + +export function parseUrl(url: string): ParsedUrl { + if (url.startsWith('/')) { + return parseRelativeUrl(url) + } + + const parsedURL = new URL(url) + return { + hash: parsedURL.hash, + hostname: parsedURL.hostname, + href: parsedURL.href, + pathname: parsedURL.pathname, + port: parsedURL.port, + protocol: parsedURL.protocol, + query: searchParamsToUrlQuery(parsedURL.searchParams), + search: parsedURL.search, + } +} diff --git a/packages/next/shared/lib/router/utils/prepare-destination.ts b/packages/next/shared/lib/router/utils/prepare-destination.ts index 429eb7f538a1a..79de9ee20bdcc 100644 --- a/packages/next/shared/lib/router/utils/prepare-destination.ts +++ b/packages/next/shared/lib/router/utils/prepare-destination.ts @@ -1,9 +1,8 @@ -import { IncomingMessage } from 'http' -import { ParsedUrlQuery } from 'querystring' -import { searchParamsToUrlQuery } from './querystring' -import { parseRelativeUrl } from './parse-relative-url' +import type { IncomingMessage } from 'http' +import type { ParsedUrlQuery } from 'querystring' +import { parseUrl } from './parse-url' import * as pathToRegexp from 'next/dist/compiled/path-to-regexp' -import { RouteHas } from '../../../../lib/load-custom-routes' +import type { RouteHas } from '../../../../lib/load-custom-routes' type Params = { [param: string]: any } @@ -147,45 +146,13 @@ export default function prepareDestination( query: ParsedUrlQuery, appendParamsToQuery: boolean ) { - let parsedDestination: { - query?: ParsedUrlQuery - protocol?: string - hostname?: string - port?: string - } & ReturnType = {} as any - // clone query so we don't modify the original query = Object.assign({}, query) const hadLocale = query.__nextLocale delete query.__nextLocale delete query.__nextDefaultLocale - if (destination.startsWith('/')) { - parsedDestination = parseRelativeUrl(destination) - } else { - const { - pathname, - searchParams, - hash, - hostname, - port, - protocol, - search, - href, - } = new URL(destination) - - parsedDestination = { - pathname, - query: searchParamsToUrlQuery(searchParams), - hash, - protocol, - hostname, - port, - search, - href, - } - } - + const parsedDestination = parseUrl(destination) const destQuery = parsedDestination.query const destPath = `${parsedDestination.pathname!}${ parsedDestination.hash || '' diff --git a/packages/next/shared/lib/router/utils/route-regex.ts b/packages/next/shared/lib/router/utils/route-regex.ts index b7a3bf45f21f2..a3a24dc755b05 100644 --- a/packages/next/shared/lib/router/utils/route-regex.ts +++ b/packages/next/shared/lib/router/utils/route-regex.ts @@ -1,4 +1,4 @@ -export interface Group { +interface Group { pos: number repeat: boolean optional: boolean @@ -22,17 +22,8 @@ function parseParameter(param: string) { return { key: param, repeat, optional } } -export function getRouteRegex( - normalizedRoute: string -): { - re: RegExp - namedRegex?: string - routeKeys?: { [named: string]: string } - groups: { [groupName: string]: Group } -} { - const segments = (normalizedRoute.replace(/\/$/, '') || '/') - .slice(1) - .split('/') +export function getParametrizedRoute(route: string) { + const segments = (route.replace(/\/$/, '') || '/').slice(1).split('/') const groups: { [groupName: string]: Group } = {} let groupIndex = 1 @@ -107,15 +98,39 @@ export function getRouteRegex( .join('') return { - re: new RegExp(`^${parameterizedRoute}(?:/)?$`), + parameterizedRoute, + namedParameterizedRoute, groups, routeKeys, - namedRegex: `^${namedParameterizedRoute}(?:/)?$`, } } return { - re: new RegExp(`^${parameterizedRoute}(?:/)?$`), + parameterizedRoute, groups, } } + +export interface RouteRegex { + groups: { [groupName: string]: Group } + namedRegex?: string + re: RegExp + routeKeys?: { [named: string]: string } +} + +export function getRouteRegex(normalizedRoute: string): RouteRegex { + const result = getParametrizedRoute(normalizedRoute) + if ('routeKeys' in result) { + return { + re: new RegExp(`^${result.parameterizedRoute}(?:/)?$`), + groups: result.groups, + routeKeys: result.routeKeys, + namedRegex: `^${result.namedParameterizedRoute}(?:/)?$`, + } + } + + return { + re: new RegExp(`^${result.parameterizedRoute}(?:/)?$`), + groups: result.groups, + } +} diff --git a/packages/next/shared/lib/utils.ts b/packages/next/shared/lib/utils.ts index eeeaa61d2bf38..beae26a60e8e1 100644 --- a/packages/next/shared/lib/utils.ts +++ b/packages/next/shared/lib/utils.ts @@ -1,13 +1,13 @@ -import { IncomingMessage, ServerResponse } from 'http' -import { ParsedUrlQuery } from 'querystring' -import { ComponentType } from 'react' -import { UrlObject } from 'url' import { formatUrl } from './router/utils/format-url' -import { NextRouter } from './router/router' -import { Env } from '@next/env' -import { BuildManifest } from '../../server/get-page-files' -import { DomainLocales } from '../../server/config' -import { PreviewData } from 'next/types' +import type { BuildManifest } from '../../server/get-page-files' +import type { ComponentType } from 'react' +import type { DomainLocale } from '../../server/config' +import type { Env } from '@next/env' +import type { IncomingMessage, ServerResponse } from 'http' +import type { NextRouter } from './router/router' +import type { ParsedUrlQuery } from 'querystring' +import type { PreviewData } from 'next/types' +import type { UrlObject } from 'url' export type NextComponentType< C extends BaseContext = NextPageContext, @@ -108,7 +108,7 @@ export type NEXT_DATA = { locale?: string locales?: string[] defaultLocale?: string - domainLocales?: DomainLocales + domainLocales?: DomainLocale[] scriptLoader?: any[] isPreview?: boolean }