Skip to content
This repository has been archived by the owner on Jun 21, 2023. It is now read-only.

Commit

Permalink
Refactor i18n checks on request handling (vercel#27328)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
javivelasco authored Jul 21, 2021
1 parent 2a64e3e commit 3673b77
Show file tree
Hide file tree
Showing 15 changed files with 331 additions and 267 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions packages/next/server/api-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 15 additions & 13 deletions packages/next/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
[P in keyof T]-?: T[P]
}

export type NextConfigComplete = NoOptionals<NextConfig>

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<Header[]>
rewrites?: () => Promise<
Expand Down
2 changes: 1 addition & 1 deletion packages/next/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
186 changes: 33 additions & 153 deletions packages/next/server/next-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -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)

Expand Down Expand Up @@ -175,7 +173,7 @@ export default class Server {
locale?: string
locales?: string[]
defaultLocale?: string
domainLocales?: DomainLocales
domainLocales?: DomainLocale[]
distDir: string
}
private compression?: Middleware
Expand Down Expand Up @@ -309,7 +307,7 @@ export default class Server {
res: ServerResponse,
parsedUrl?: UrlWithParsedQuery
): Promise<void> {
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') {
Expand All @@ -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, '') || '/'
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/next/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -79,7 +79,7 @@ class ServerRouter implements NextRouter {
isReady: boolean
locales?: string[]
defaultLocale?: string
domainLocales?: DomainLocales
domainLocales?: DomainLocale[]
isPreview: boolean
isLocaleDomain: boolean

Expand All @@ -93,7 +93,7 @@ class ServerRouter implements NextRouter {
locale?: string,
locales?: string[],
defaultLocale?: string,
domainLocales?: DomainLocales,
domainLocales?: DomainLocale[],
isPreview?: boolean,
isLocaleDomain?: boolean
) {
Expand Down Expand Up @@ -186,7 +186,7 @@ export type RenderOptsPartial = {
locale?: string
locales?: string[]
defaultLocale?: string
domainLocales?: DomainLocales
domainLocales?: DomainLocale[]
disableOptimizedLoading?: boolean
requireStaticHTML?: boolean
}
Expand Down
Loading

0 comments on commit 3673b77

Please sign in to comment.