Skip to content

Commit 436c361

Browse files
authored
feat(domain): restrict locales to a subset of domains (#4085)
1 parent b84fe0a commit 436c361

6 files changed

Lines changed: 121 additions & 9 deletions

File tree

docs/content/docs/02.guide/10.multi-domain-locales.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,46 @@ The same requests when using the `'prefix_except_default'`{lang="ts-type"} strat
170170
- https://fr.mydomain.com/en -> https://fr.mydomain.com/en (en language)
171171

172172
A locale is only unprefixed on the domains it is the default for, on every other domain it keeps its prefix. This includes the locale set as `defaultLocale`.
173+
174+
## Restricting locales to specific domains
175+
176+
A locale's `domains` doesn't have to list every domain, it only has to list the domains that locale should be served on. A locale configured without `domains` is served on all of them.
177+
178+
```ts [nuxt.config.ts]
179+
export default defineNuxtConfig({
180+
i18n: {
181+
locales: [
182+
{
183+
code: 'en',
184+
domains: ['mydomain.com'],
185+
defaultForDomains: ['mydomain.com']
186+
},
187+
{
188+
// shared between both domains
189+
code: 'fr',
190+
domains: ['mydomain.com', 'es.mydomain.com']
191+
},
192+
{
193+
code: 'es',
194+
domains: ['es.mydomain.com'],
195+
defaultForDomains: ['es.mydomain.com']
196+
}
197+
],
198+
defaultLocale: 'en',
199+
strategy: 'prefix_except_default',
200+
multiDomainLocales: true
201+
}
202+
})
203+
```
204+
205+
Given the above configuration, following requests will be:
206+
- https://mydomain.com -> https://mydomain.com (en language)
207+
- https://mydomain.com/fr -> https://mydomain.com/fr (fr language)
208+
- https://mydomain.com/es -> https://es.mydomain.com (es language, redirected to the domain serving it)
209+
- https://es.mydomain.com -> https://es.mydomain.com (es language)
210+
- https://es.mydomain.com/fr -> https://es.mydomain.com/fr (fr language)
211+
- https://es.mydomain.com/en -> https://mydomain.com (en language, redirected to the domain serving it)
212+
213+
Locales served on other domains stay available in `locales` and in the locale switcher, `switchLocalePath` links to them on the domain that serves them. Browser language detection only applies locales served on the current domain, so a visitor whose browser or cookie locale isn't available there keeps that domain's own locale.
214+
215+
A request on a host that doesn't match any configured domain, such as a staging domain or a health check by IP, isn't restricted. Every locale is served there and `defaultLocale` is used as the unprefixed default, so it's worth setting even when every domain has its own default through `defaultForDomains`.

docs/content/docs/04.api/00.options.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ See also [Multiple files lazy loading](/docs/guide/lazy-load-translations#multip
144144

145145
- type: `null | string[]`{lang="ts-type"}
146146
- An array of `domain`. This property is required when using [`multiDomainLocales`](/docs/api/options#multiDomainLocales) while one or more of the domains having multiple of the same locales
147+
- This does not have to be the same array for every locale, a locale is only served on the domains listed here. A locale without `domains` is served on all of them. See [Restricting locales to specific domains](/docs/guide/multi-domain-locales#restricting-locales-to-specific-domains)
147148

148149
### `defaultForDomains`
149150

specs/multi_domains_locales/multi_domains_locales_multi_locales.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ await setup({
3636
code: 'ja',
3737
language: 'jp-JA',
3838
name: 'Japan',
39-
domains: i18nDomains,
39+
// restricted to its own domain, the other locales stay on all of them
40+
domains: ['ja.nuxt-app.localhost'],
4041
defaultForDomains: ['ja.nuxt-app.localhost']
4142
}
4243
],
@@ -82,6 +83,49 @@ test('detection locale with x-forwarded-host on server', async () => {
8283
expect(await dom.locator('#home-header').textContent()).toEqual('Accueil')
8384
})
8485

86+
describe('locales restricted to specific domains', () => {
87+
test.each([
88+
['/ja', 'http://ja.nuxt-app.localhost'],
89+
['/ja/parent/child', 'http://ja.nuxt-app.localhost/parent/child'],
90+
['/ja/about?foo=bar', 'http://ja.nuxt-app.localhost/about?foo=bar']
91+
])('%s is relocated to the domain serving it', async (path, location) => {
92+
const res = await undiciRequest(path, { headers: { Host: 'nuxt-app.localhost' } })
93+
94+
expect(res.statusCode).toBe(302)
95+
// `ja` is the default on its own domain, so it lands there unprefixed
96+
expect(res.headers.location).toEqual(location)
97+
})
98+
99+
test('a restricted locale is served on its own domain', async () => {
100+
const res = await undiciRequest('/', { headers: { Host: 'ja.nuxt-app.localhost' } })
101+
const dom = await getDom(await res.body.text())
102+
103+
expect(res.statusCode).toBe(200)
104+
expect(await dom.locator('#lang-switcher-current-locale code').textContent()).toEqual('ja')
105+
})
106+
107+
test('a cookie holding a restricted locale does not redirect off the current domain', async () => {
108+
const res = await undiciRequest('/', {
109+
headers: { Host: 'nuxt-app.localhost', Cookie: 'i18n_redirected=ja' }
110+
})
111+
const dom = await getDom(await res.body.text())
112+
113+
expect(res.statusCode).toBe(200)
114+
expect(await dom.locator('#lang-switcher-current-locale code').textContent()).toEqual('en')
115+
})
116+
117+
test('a restricted locale stays in the switcher and links to its own domain', async () => {
118+
const res = await undiciRequest('/', { headers: { Host: 'nuxt-app.localhost' } })
119+
const dom = await getDom(await res.body.text())
120+
121+
expect(await dom.locator('#switch-locale-path-usages .switch-to-ja a').getAttribute('href')).toEqual(
122+
'http://ja.nuxt-app.localhost'
123+
)
124+
// locales served on the current host keep linking relative
125+
expect(await dom.locator('#switch-locale-path-usages .switch-to-no a').getAttribute('href')).toEqual('/no')
126+
})
127+
})
128+
85129
describe('detection locale with child routes', () => {
86130
test.each([
87131
['/parent/child', 'nuxt-app.localhost', 'Parent route test', 'Child route test'],

src/runtime/server/plugin.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { defineNitroPlugin, useRuntimeConfig, useStorage } from 'nitropack/runti
44
import { initializeI18nContext, tryUseI18nContext, useI18nContext } from './context'
55
import { createUserLocaleDetector } from './utils/locale-detector'
66
import { pickNested } from './utils/messages-utils'
7-
import { isSupportedLocale } from '../shared/locales'
7+
import { getDefaultLocaleForDomain, isSupportedLocale } from '../shared/locales'
88
import { setupVueI18nOptions } from '../shared/vue-i18n'
99
import { joinURL, parsePath } from 'ufo'
1010
// @ts-expect-error virtual file
@@ -16,7 +16,7 @@ import { isFunction } from '@intlify/shared'
1616
import { type H3Event, getRequestURL, sanitizeStatusCode, setCookie } from 'h3'
1717
import type { CoreOptions } from '@intlify/core'
1818
import { useDetectors } from '../shared/detection'
19-
import { domainFromLocale } from '../shared/domain'
19+
import { domainFromLocale, normalizeDomain } from '../shared/domain'
2020
import { isExistingNuxtRoute, matchLocalized } from '../shared/matching'
2121
import { createRedirectResolver } from './utils/redirect'
2222

@@ -91,6 +91,22 @@ export default defineNitroPlugin(async (nitro) => {
9191

9292
const baseUrlGetter = createBaseUrlGetter()
9393

94+
/**
95+
* Redirect moving a locale path to the domain that serves it, the target domain has its own
96+
* default locale so the path is prefixed for that domain rather than the current one.
97+
*/
98+
const resolveRelocation = (event: H3Event, pathLocale: string, path: string) => {
99+
const origin = getDomainFromLocale(event, pathLocale)
100+
const host = normalizeDomain(origin)
101+
// a target on the current host would redirect to itself
102+
if (!origin || host === getRequestURL(event, { xForwardedHost: true }).host) { return }
103+
104+
const relocated = matchLocalized(path, pathLocale, getDefaultLocaleForDomain(host) || _defaultLocale)
105+
if (!relocated) { return }
106+
107+
return { path: relocated, code: runtimeI18n.redirectStatusCode ?? 302, locale: pathLocale, origin }
108+
}
109+
94110
nitro.hooks.hook('request', async (event: H3Event) => {
95111
await initializeI18nContext(event)
96112
})
@@ -107,22 +123,29 @@ export default defineNitroPlugin(async (nitro) => {
107123
// `event.path` is already stripped of `app.baseURL` by Nitro (unlike `url.pathname`), and
108124
// `parsePath` drops any query string - so `path` stays an absolute, base-free route path.
109125
const { pathname } = parsePath(event.path)
110-
const path = (pathLocale && pathname.slice(pathLocale.length + 1)) ?? pathname
126+
// a locale-prefixed root (`/ja`) leaves nothing behind the prefix, its route path is `/`
127+
const path = (pathLocale ? pathname.slice(pathLocale.length + 1) || '/' : pathname)
111128

112129
// attempt to only run i18n detection for nuxt pages and i18n server routes
113130
if (!url.pathname.includes(__I18N_SERVER_ROUTE__) && !isExistingNuxtRoute(path)) {
114131
return
115132
}
116133

117-
const resolved = resolveRedirectPath(event.path, path, pathLocale, ctx.vueI18nOptions!.defaultLocale, detector)
118-
if (resolved.path && resolved.path !== pathname) {
134+
// a locale restricted to other domains cannot be served here, the request moves to a domain
135+
// that does serve it rather than rendering its path in the wrong locale
136+
const relocation = (__I18N_ROUTING__ && __I18N_DOMAINS__ && pathLocale && !detector.onHost(pathLocale)
137+
&& resolveRelocation(event, pathLocale, path)) || undefined
138+
139+
const resolved = relocation || resolveRedirectPath(event.path, path, pathLocale, ctx.vueI18nOptions!.defaultLocale, detector)
140+
if (resolved.path && (relocation || resolved.path !== pathname)) {
119141
ctx.detectLocale = resolved.locale
120-
detection.useCookie && setCookie(event, detection.cookieKey, resolved.locale, cookieOptions)
142+
// the origin host would not send the cookie to the domain being redirected to
143+
!relocation && detection.useCookie && setCookie(event, detection.cookieKey, resolved.locale, cookieOptions)
121144
context.response = createRedirectResponse(
122145
event,
123146
// the resolved path is base-free (matched against base-free routes), re-add `app.baseURL`
124147
joinURL(
125-
baseUrlGetter(event, ctx.vueI18nOptions!.defaultLocale),
148+
relocation?.origin || baseUrlGetter(event, ctx.vueI18nOptions!.defaultLocale),
126149
useRuntimeConfig(event).app.baseURL,
127150
resolved.path + url.search,
128151
),

src/runtime/shared/domain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { I18nPublicRuntimeConfig, LocaleObject } from '#internal-i18n-types
99
* against the request host use the host part only. `ufo` helpers are unsuitable here
1010
* as they treat the `host:port` shape as a protocol.
1111
*/
12-
export const normalizeDomain = (domain: string = '') => domain.replace(/^https?:\/\//, '').toLowerCase()
12+
export const normalizeDomain = (domain: string = '') => domain.replace(/^https?:\/\//i, '').toLowerCase()
1313

1414
/**
1515
* Whether the locale is served on the given host

test/domain.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('normalizeDomain', () => {
2828
expect(normalizeDomain()).toBe('')
2929
// request hosts are always lowercase, a configured domain may not be
3030
expect(normalizeDomain('https://EN.Example.com')).toBe('en.example.com')
31+
expect(normalizeDomain('HTTPS://EN.Example.com')).toBe('en.example.com')
3132
})
3233
})
3334

0 commit comments

Comments
 (0)