From cda8665f50d6d4be2ea393cdca9fd099a7f099dc Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 13 Oct 2020 17:32:00 -0500 Subject: [PATCH] Update redirect handling for locale domains --- .../webpack/loaders/next-serverless-loader.ts | 77 ++++++++++----- .../lib/i18n/detect-domain-locale.ts | 41 ++++++++ .../lib/i18n/detect-domain-locales.ts | 37 ------- packages/next/next-server/server/config.ts | 15 --- .../next/next-server/server/next-server.ts | 97 ++++++++++--------- packages/next/next-server/server/render.tsx | 2 - test/integration/i18n-support/next.config.js | 6 +- .../i18n-support/test/index.test.js | 59 +++++++---- 8 files changed, 191 insertions(+), 143 deletions(-) create mode 100644 packages/next/next-server/lib/i18n/detect-domain-locale.ts delete mode 100644 packages/next/next-server/lib/i18n/detect-domain-locales.ts diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 6d669310f7fa86d..d23c711f5a9c9da 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -222,64 +222,95 @@ const nextServerlessLoader: loader.Loader = function () { const i18n = ${i18n} const accept = require('@hapi/accept') const { detectLocaleCookie } = require('next/dist/next-server/lib/i18n/detect-locale-cookie') - const { detectDomainLocales } = require('next/dist/next-server/lib/i18n/detect-domain-locales') + const { detectDomainLocale } = require('next/dist/next-server/lib/i18n/detect-domain-locale') const { normalizeLocalePath } = require('next/dist/next-server/lib/i18n/normalize-locale-path') + let locales = i18n.locales + let defaultLocale = i18n.defaultLocale let detectedLocale = detectLocaleCookie(req, i18n.locales) - const { defaultLocale, locales } = detectDomainLocales( - req, + const detectedDomain = detectDomainLocale( i18n.domains, - i18n.locales, - i18n.defaultLocale, + req, ) + if (detectedDomain) { + defaultLocale = detectedDomain.defaultLocale + detectedLocale = defaultLocale + } if (!detectedLocale) { detectedLocale = accept.language( req.headers['accept-language'], - locales + i18n.locales ) } + let localeDomainRedirect + const localePathResult = normalizeLocalePath(parsedUrl.pathname, i18n.locales) + + if (localePathResult.detectedLocale) { + detectedLocale = localePathResult.detectedLocale + req.url = formatUrl({ + ...parsedUrl, + pathname: localePathResult.pathname, + }) + parsedUrl.pathname = localePathResult.pathname + + // check if the locale prefix matches a domain's defaultLocale + // and we're on a locale specific domain if so redirect to that domain + if (detectedDomain) { + const matchedDomain = detectDomainLocale( + i18n.domains, + undefined, + detectedLocale + ) + + if (matchedDomain) { + localeDomainRedirect = \`http\${ + matchedDomain.http ? '' : 's' + }://\${matchedDomain.domain}\` + } + } + } + const denormalizedPagePath = denormalizePagePath(parsedUrl.pathname || '/') - const detectedDefaultLocale = !detectedLocale || detectedLocale.toLowerCase() === defaultLocale.toLowerCase() + const detectedDefaultLocale = + !detectedLocale || + detectedLocale.toLowerCase() === defaultLocale.toLowerCase() const shouldStripDefaultLocale = detectedDefaultLocale && - denormalizedPagePath.toLowerCase() === \`/\${defaultLocale.toLowerCase()}\` + denormalizedPagePath.toLowerCase() === \`/\${i18n.defaultLocale.toLowerCase()}\` const shouldAddLocalePrefix = !detectedDefaultLocale && denormalizedPagePath === '/' - detectedLocale = detectedLocale || defaultLocale + detectedLocale = detectedLocale || i18n.defaultLocale if ( !fromExport && !nextStartMode && i18n.localeDetection !== false && - (shouldAddLocalePrefix || shouldStripDefaultLocale) + ( + localeDomainRedirect || + shouldAddLocalePrefix || + shouldStripDefaultLocale + ) ) { res.setHeader( 'Location', formatUrl({ // make sure to include any query values when redirecting ...parsedUrl, - pathname: shouldStripDefaultLocale ? '/' : \`/\${detectedLocale}\`, + pathname: + localeDomainRedirect + ? localeDomainRedirect + : shouldStripDefaultLocale + ? '/' + : \`/\${detectedLocale}\`, }) ) res.statusCode = 307 res.end() return } - - const localePathResult = normalizeLocalePath(parsedUrl.pathname, locales) - - if (localePathResult.detectedLocale) { - detectedLocale = localePathResult.detectedLocale - req.url = formatUrl({ - ...parsedUrl, - pathname: localePathResult.pathname, - }) - parsedUrl.pathname = localePathResult.pathname - } - detectedLocale = detectedLocale || defaultLocale ` : ` diff --git a/packages/next/next-server/lib/i18n/detect-domain-locale.ts b/packages/next/next-server/lib/i18n/detect-domain-locale.ts new file mode 100644 index 000000000000000..e97389c9ea5598d --- /dev/null +++ b/packages/next/next-server/lib/i18n/detect-domain-locale.ts @@ -0,0 +1,41 @@ +import { IncomingMessage } from 'http' + +export function detectDomainLocale( + domainItems: + | Array<{ + http?: boolean + domain: string + defaultLocale: string + }> + | undefined, + req?: IncomingMessage, + detectedLocale?: string +) { + let domainItem: + | { + http?: boolean + domain: string + defaultLocale: string + } + | undefined + + if (domainItems) { + const { host } = req?.headers || {} + // remove port from host and remove port if present + const hostname = host?.split(':')[0].toLowerCase() + + for (const item of domainItems) { + // remove port if present + const domainHostname = item.domain?.split(':')[0].toLowerCase() + if ( + hostname === domainHostname || + detectedLocale?.toLowerCase() === item.defaultLocale.toLowerCase() + ) { + domainItem = item + break + } + } + } + + return domainItem +} diff --git a/packages/next/next-server/lib/i18n/detect-domain-locales.ts b/packages/next/next-server/lib/i18n/detect-domain-locales.ts deleted file mode 100644 index f91870643efc410..000000000000000 --- a/packages/next/next-server/lib/i18n/detect-domain-locales.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { IncomingMessage } from 'http' - -export function detectDomainLocales( - req: IncomingMessage, - domainItems: - | Array<{ - domain: string - locales: string[] - defaultLocale: string - }> - | undefined, - locales: string[], - defaultLocale: string -) { - let curDefaultLocale = defaultLocale - let curLocales = locales - - const { host } = req.headers - - if (host && domainItems) { - // remove port from host and remove port if present - const hostname = host.split(':')[0].toLowerCase() - - for (const item of domainItems) { - if (hostname === item.domain.toLowerCase()) { - curDefaultLocale = item.defaultLocale - curLocales = item.locales - break - } - } - } - - return { - defaultLocale: curDefaultLocale, - locales: curLocales, - } -} diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index e765cddb20b7564..b659c4f030321a7 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -238,22 +238,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (!item || typeof item !== 'object') return true if (!item.defaultLocale) return true if (!item.domain || typeof item.domain !== 'string') return true - if (!item.locales || !Array.isArray(item.locales)) return true - const invalidLocales = item.locales.filter( - (locale: string) => !i18n.locales.includes(locale) - ) - - if (invalidLocales.length > 0) { - console.error( - `i18n.domains item "${ - item.domain - }" has the following locales (${invalidLocales.join( - ', ' - )}) that aren't provided in the main i18n.locales. Add them to the i18n.locales list or remove them from the domains item locales to continue.\n` - ) - return true - } return false }) diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 69a4b75f00f07a3..78c5c61b5896156 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -79,7 +79,7 @@ import accept from '@hapi/accept' import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path' import { detectLocaleCookie } from '../lib/i18n/detect-locale-cookie' import * as Log from '../../build/output/log' -import { detectDomainLocales } from '../lib/i18n/detect-domain-locales' +import { detectDomainLocale } from '../lib/i18n/detect-domain-locale' const getCustomRouteMatcher = pathMatch(true) @@ -306,67 +306,85 @@ export default class Server { if (i18n && !parsedUrl.pathname?.startsWith('/_next')) { // get pathname from URL with basePath stripped for locale detection const { pathname, ...parsed } = parseUrl(req.url || '/') + let defaultLocale = i18n.defaultLocale let detectedLocale = detectLocaleCookie(req, i18n.locales) - const { defaultLocale, locales } = detectDomainLocales( - req, - i18n.domains, - i18n.locales, - i18n.defaultLocale - ) + const detectedDomain = detectDomainLocale(i18n.domains, req) + if (detectedDomain) { + defaultLocale = detectedDomain.defaultLocale + detectedLocale = defaultLocale + } if (!detectedLocale) { detectedLocale = accept.language( req.headers['accept-language'], - locales + i18n.locales ) } + let localeDomainRedirect: string | undefined + const localePathResult = normalizeLocalePath(pathname!, i18n.locales) + + if (localePathResult.detectedLocale) { + detectedLocale = localePathResult.detectedLocale + req.url = formatUrl({ + ...parsed, + pathname: localePathResult.pathname, + }) + parsedUrl.pathname = localePathResult.pathname + + // check if the locale prefix matches a domain's defaultLocale + // and we're on a locale specific domain if so redirect to that domain + if (detectedDomain) { + const matchedDomain = detectDomainLocale( + i18n.domains, + undefined, + detectedLocale + ) + + if (matchedDomain) { + localeDomainRedirect = `http${matchedDomain.http ? '' : 's'}://${ + matchedDomain?.domain + }` + } + } + } + const denormalizedPagePath = denormalizePagePath(pathname || '/') const detectedDefaultLocale = !detectedLocale || detectedLocale.toLowerCase() === defaultLocale.toLowerCase() const shouldStripDefaultLocale = detectedDefaultLocale && - denormalizedPagePath.toLowerCase() === `/${defaultLocale.toLowerCase()}` + denormalizedPagePath.toLowerCase() === + `/${i18n.defaultLocale.toLowerCase()}` const shouldAddLocalePrefix = !detectedDefaultLocale && denormalizedPagePath === '/' - detectedLocale = detectedLocale || defaultLocale + detectedLocale = detectedLocale || i18n.defaultLocale if ( i18n.localeDetection !== false && - (shouldAddLocalePrefix || shouldStripDefaultLocale) + (localeDomainRedirect || + shouldAddLocalePrefix || + shouldStripDefaultLocale) ) { res.setHeader( 'Location', formatUrl({ // make sure to include any query values when redirecting ...parsed, - pathname: shouldStripDefaultLocale ? '/' : `/${detectedLocale}`, + pathname: localeDomainRedirect + ? localeDomainRedirect + : shouldStripDefaultLocale + ? '/' + : `/${detectedLocale}`, }) ) res.statusCode = 307 res.end() return } - - const localePathResult = normalizeLocalePath(pathname!, locales) - - if (localePathResult.detectedLocale) { - detectedLocale = localePathResult.detectedLocale - req.url = formatUrl({ - ...parsed, - pathname: localePathResult.pathname, - }) - parsedUrl.pathname = localePathResult.pathname - } - - // TODO: render with domain specific locales and defaultLocale also? - // Currently locale specific domains will have all locales populated - // under router.locales instead of only the domain specific ones - parsedUrl.query.__nextLocales = i18n.locales - // parsedUrl.query.__nextDefaultLocale = defaultLocale parsedUrl.query.__nextLocale = detectedLocale || defaultLocale } @@ -517,21 +535,15 @@ export default class Server { if (i18n) { const localePathResult = normalizeLocalePath(pathname, i18n.locales) - const { defaultLocale } = detectDomainLocales( - req, - i18n.domains, - i18n.locales, - i18n.defaultLocale - ) + const { defaultLocale } = + detectDomainLocale(i18n.domains, req) || {} let detectedLocale = defaultLocale if (localePathResult.detectedLocale) { pathname = localePathResult.pathname detectedLocale = localePathResult.detectedLocale } - _parsedUrl.query.__nextLocales = i18n.locales - _parsedUrl.query.__nextLocale = detectedLocale - // _parsedUrl.query.__nextDefaultLocale = defaultLocale + _parsedUrl.query.__nextLocale = detectedLocale! } pathname = getRouteFromAssetPath(pathname, '.json') @@ -1078,8 +1090,6 @@ export default class Server { amp: query.amp, _nextDataReq: query._nextDataReq, __nextLocale: query.__nextLocale, - __nextLocales: query.__nextLocales, - // __nextDefaultLocale: query.__nextDefaultLocale, } : query), ...(params || {}), @@ -1151,11 +1161,10 @@ export default class Server { delete query._nextDataReq const locale = query.__nextLocale as string - const locales = query.__nextLocales as string[] - // const defaultLocale = query.__nextDefaultLocale as string delete query.__nextLocale - delete query.__nextLocales - // delete query.__nextDefaultLocale + + const { i18n } = this.nextConfig.experimental + const locales = i18n.locales as string[] let previewData: string | false | object | undefined let isPreviewMode = false diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 590a008ac0e09cb..332823e52612af0 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -414,8 +414,6 @@ export async function renderToHTML( const isFallback = !!query.__nextFallback delete query.__nextFallback delete query.__nextLocale - delete query.__nextLocales - delete query.__nextDefaultLocale const isSSG = !!getStaticProps const isBuildTimeSSG = isSSG && renderOpts.nextExport diff --git a/test/integration/i18n-support/next.config.js b/test/integration/i18n-support/next.config.js index f07b32b6c9a0d0a..b49138d81668032 100644 --- a/test/integration/i18n-support/next.config.js +++ b/test/integration/i18n-support/next.config.js @@ -6,13 +6,15 @@ module.exports = { defaultLocale: 'en-US', domains: [ { + // used for testing, this should not be needed in most cases + // as production domains should always use https + http: true, domain: 'example.be', defaultLocale: 'nl-BE', - locales: ['nl-BE', 'fr-BE'], }, { + http: true, domain: 'example.fr', - locales: ['fr', 'fr-BE'], defaultLocale: 'fr', }, ], diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index f0cd92b4da62c87..00f1bf163ce457a 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -132,13 +132,38 @@ function runTests(isDev) { expect(result2.query).toEqual({}) }) - it('should only handle the domain specific locales', async () => { - const checkDomainLocales = async ( - domainLocales = [], - domainDefault = '', - domain = '' - ) => { + it('should redirect to correct locale domain', async () => { + const checks = [ + // test domain, locale prefix, redirect result + ['example.be', 'nl-BE', 'http://example.be/'], + ['example.be', 'fr', 'http://example.fr/'], + ['example.fr', 'nl-BE', 'http://example.be/'], + ['example.fr', 'fr', 'http://example.fr/'], + ] + + for (const check of checks) { + const [domain, localePath, location] = check + + const res = await fetchViaHTTP(appPort, `/${localePath}`, undefined, { + headers: { + host: domain, + }, + redirect: 'manual', + }) + + expect(res.status).toBe(307) + expect(res.headers.get('location')).toBe(location) + } + }) + + it('should handle locales with domain', async () => { + const checkDomainLocales = async (domainDefault = '', domain = '') => { for (const locale of locales) { + // skip other domains' default locale since we redirect these + if (['fr', 'nl-BE'].includes(locale) && locale !== domainDefault) { + continue + } + const res = await fetchViaHTTP( appPort, `/${locale === domainDefault ? '' : locale}`, @@ -151,25 +176,19 @@ function runTests(isDev) { } ) - const isDomainLocale = domainLocales.includes(locale) + expect(res.status).toBe(200) - expect(res.status).toBe(isDomainLocale ? 200 : 404) + const html = await res.text() + const $ = cheerio.load(html) - if (isDomainLocale) { - const html = await res.text() - const $ = cheerio.load(html) - - expect($('html').attr('lang')).toBe(locale) - expect($('#router-locale').text()).toBe(locale) - // expect(JSON.parse($('#router-locales').text())).toEqual(domainLocales) - expect(JSON.parse($('#router-locales').text())).toEqual(locales) - } + expect($('html').attr('lang')).toBe(locale) + expect($('#router-locale').text()).toBe(locale) + expect(JSON.parse($('#router-locales').text())).toEqual(locales) } } - await checkDomainLocales(['nl-BE', 'fr-BE'], 'nl-BE', 'example.be') - - await checkDomainLocales(['fr', 'fr-BE'], 'fr', 'example.fr') + await checkDomainLocales('nl-BE', 'example.be') + await checkDomainLocales('fr', 'example.fr') }) it('should generate fallbacks with all locales', async () => {