diff --git a/.changeset/cold-sloths-vanish.md b/.changeset/cold-sloths-vanish.md new file mode 100644 index 000000000..2862a61e2 --- /dev/null +++ b/.changeset/cold-sloths-vanish.md @@ -0,0 +1,6 @@ +--- +"@opennextjs/aws": patch +--- + +Fix locale not properly defined when used in middleware with domains +Handle locale redirect directly in the routing layer diff --git a/packages/open-next/src/core/routing/i18n/index.ts b/packages/open-next/src/core/routing/i18n/index.ts index fb1f16d72..2c532dc6d 100644 --- a/packages/open-next/src/core/routing/i18n/index.ts +++ b/packages/open-next/src/core/routing/i18n/index.ts @@ -1,8 +1,10 @@ import { NextConfig } from "config/index.js"; -import type { i18nConfig } from "types/next-types"; -import type { InternalEvent } from "types/open-next"; +import type { DomainLocale, i18nConfig } from "types/next-types"; +import type { InternalEvent, InternalResult } from "types/open-next"; +import { emptyReadableStream } from "utils/stream.js"; import { debug } from "../../../adapters/logger.js"; +import { constructNextUrl } from "../util.js"; import { acceptLanguage } from "./accept-header"; function isLocalizedPath(path: string): boolean { @@ -20,6 +22,34 @@ function getLocaleFromCookie(cookies: Record) { : undefined; } +// Inspired by https://github.com/vercel/next.js/blob/6d93d652e0e7ba72d9a3b66e78746dce2069db03/packages/next/src/shared/lib/i18n/detect-domain-locale.ts#L3-L25 +export function detectDomainLocale({ + hostname, + detectedLocale, +}: { + hostname?: string; + detectedLocale?: string; +}): DomainLocale | undefined { + const i18n = NextConfig.i18n; + if (!i18n || i18n.localeDetection === false || !i18n.domains) { + return; + } + const lowercasedLocale = detectedLocale?.toLowerCase(); + for (const domain of i18n.domains) { + // We remove the port if present + const domainHostname = domain.domain.split(":", 1)[0].toLowerCase(); + if ( + hostname === domainHostname || + lowercasedLocale === domain.defaultLocale.toLowerCase() || + domain.locales?.some( + (locale) => lowercasedLocale === locale.toLowerCase(), + ) + ) { + return domain; + } + } +} + export function detectLocale( internalEvent: InternalEvent, i18n: i18nConfig, @@ -39,9 +69,16 @@ export function detectLocale( defaultLocale: i18n.defaultLocale, }); - return cookiesLocale ?? preferredLocale ?? i18n.defaultLocale; + const domainLocale = detectDomainLocale({ + hostname: internalEvent.headers.host, + }); - // TODO: handle domain based locale detection + return ( + domainLocale?.defaultLocale ?? + cookiesLocale ?? + preferredLocale ?? + i18n.defaultLocale + ); } export function localizePath(internalEvent: InternalEvent): string { @@ -52,6 +89,73 @@ export function localizePath(internalEvent: InternalEvent): string { if (isLocalizedPath(internalEvent.rawPath)) { return internalEvent.rawPath; } + const detectedLocale = detectLocale(internalEvent, i18n); + return `/${detectedLocale}${internalEvent.rawPath}`; } + +/** + * + * @param internalEvent + * In this function, for domain locale redirect we need to rely on the host to be present and correct + * @returns `false` if no redirect is needed, `InternalResult` if a redirect is needed + */ +export function handleLocaleRedirect( + internalEvent: InternalEvent, +): false | InternalResult { + const i18n = NextConfig.i18n; + if ( + !i18n || + i18n.localeDetection === false || + internalEvent.rawPath !== "/" + ) { + return false; + } + const preferredLocale = acceptLanguage( + internalEvent.headers["accept-language"], + i18n?.locales, + ); + + const detectedLocale = detectLocale(internalEvent, i18n); + + const domainLocale = detectDomainLocale({ + hostname: internalEvent.headers.host, + }); + const preferredDomain = detectDomainLocale({ + detectedLocale: preferredLocale, + }); + + if (domainLocale && preferredDomain) { + const isPDomain = preferredDomain.domain === domainLocale.domain; + const isPLocale = preferredDomain.defaultLocale === preferredLocale; + if (!isPDomain || !isPLocale) { + const scheme = `http${preferredDomain.http ? "" : "s"}`; + const rlocale = isPLocale ? "" : preferredLocale; + return { + type: "core", + statusCode: 307, + headers: { + Location: `${scheme}://${preferredDomain.domain}/${rlocale}`, + }, + body: emptyReadableStream(), + isBase64Encoded: false, + }; + } + } + + const defaultLocale = domainLocale?.defaultLocale ?? i18n.defaultLocale; + + if (detectedLocale.toLowerCase() !== defaultLocale.toLowerCase()) { + return { + type: "core", + statusCode: 307, + headers: { + Location: constructNextUrl(internalEvent.url, `/${detectedLocale}`), + }, + body: emptyReadableStream(), + isBase64Encoded: false, + }; + } + return false; +} diff --git a/packages/open-next/src/core/routing/matcher.ts b/packages/open-next/src/core/routing/matcher.ts index 6decb37b6..8e12b58d7 100644 --- a/packages/open-next/src/core/routing/matcher.ts +++ b/packages/open-next/src/core/routing/matcher.ts @@ -12,7 +12,7 @@ import type { InternalEvent, InternalResult } from "types/open-next"; import { emptyReadableStream, toReadableStream } from "utils/stream"; import { debug } from "../../adapters/logger"; -import { localizePath } from "./i18n"; +import { handleLocaleRedirect, localizePath } from "./i18n"; import { constructNextUrl, convertFromQueryString, @@ -317,6 +317,10 @@ export function handleRedirects( ): InternalResult | undefined { const trailingSlashRedirect = handleTrailingSlashRedirect(event); if (trailingSlashRedirect) return trailingSlashRedirect; + + const localeRedirect = handleLocaleRedirect(event); + if (localeRedirect) return localeRedirect; + const { internalEvent, __rewrite } = handleRewrites( event, redirects.filter((r) => !r.internal), diff --git a/packages/open-next/src/core/routing/util.ts b/packages/open-next/src/core/routing/util.ts index 5aeab0977..5e15e5c54 100644 --- a/packages/open-next/src/core/routing/util.ts +++ b/packages/open-next/src/core/routing/util.ts @@ -87,7 +87,8 @@ export function getUrlParts(url: string, isExternal: boolean) { * @__PURE__ */ export function constructNextUrl(baseUrl: string, path: string) { - const nextBasePath = NextConfig.basePath; + // basePath is generated as "" if not provided on Next.js 15 (not sure about older versions) + const nextBasePath = NextConfig.basePath ?? ""; const url = new URL(`${nextBasePath}${path}`, baseUrl); return url.href; } diff --git a/packages/open-next/src/types/next-types.ts b/packages/open-next/src/types/next-types.ts index 4d0803e05..cac694f9a 100644 --- a/packages/open-next/src/types/next-types.ts +++ b/packages/open-next/src/types/next-types.ts @@ -61,9 +61,17 @@ export type Header = { missing?: RouteHas[]; }; +export interface DomainLocale { + defaultLocale: string; + domain: string; + http?: true; + locales: readonly string[]; +} + export interface i18nConfig { locales: string[]; defaultLocale: string; + domains?: DomainLocale[]; localeDetection?: false; } export interface NextConfig { diff --git a/packages/tests-unit/tests/core/routing/i18n.test.ts b/packages/tests-unit/tests/core/routing/i18n.test.ts index 65292f040..e4a90cf6b 100644 --- a/packages/tests-unit/tests/core/routing/i18n.test.ts +++ b/packages/tests-unit/tests/core/routing/i18n.test.ts @@ -1,8 +1,11 @@ import { NextConfig } from "@opennextjs/aws/adapters/config/index.js"; -import { localizePath } from "@opennextjs/aws/core/routing/i18n/index.js"; +import { + handleLocaleRedirect, + localizePath, +} from "@opennextjs/aws/core/routing/i18n/index.js"; import { convertFromQueryString } from "@opennextjs/aws/core/routing/util.js"; import type { InternalEvent } from "@opennextjs/aws/types/open-next.js"; -import { vi } from "vitest"; +import { expect, vi } from "vitest"; vi.mock("@opennextjs/aws/adapters/config/index.js", () => { return { @@ -15,16 +18,14 @@ vi.mock("@opennextjs/aws/adapters/config/index.js", () => { }; }); -vi.mock("@opennextjs/aws/core/routing/i18n/accept-header.js", () => ({ - acceptLanguage: (header: string, _?: string[]) => (header ? "fr" : undefined), -})); - type PartialEvent = Partial< Omit > & { body?: string }; function createEvent(event: PartialEvent): InternalEvent { - const [rawPath, qs] = (event.url ?? "/").split("?", 2); + const url = new URL(event.url ?? "/"); + const rawPath = url.pathname; + const qs = url.search.slice(1); return { type: "core", method: event.method ?? "GET", @@ -32,7 +33,7 @@ function createEvent(event: PartialEvent): InternalEvent { url: event.url ?? "/", body: Buffer.from(event.body ?? ""), headers: event.headers ?? {}, - query: convertFromQueryString(qs ?? ""), + query: convertFromQueryString(qs), cookies: event.cookies ?? {}, remoteAddress: event.remoteAddress ?? "::1", }; @@ -49,7 +50,7 @@ describe("localizePath", () => { .mockReturnValue(undefined); const event = createEvent({ - url: "/foo", + url: "http://localhost/foo", }); const result = localizePath(event); @@ -67,7 +68,7 @@ describe("localizePath", () => { }); const event = createEvent({ - url: "/foo", + url: "http://localhost/foo", headers: { "accept-language": "fr", }, @@ -85,7 +86,7 @@ describe("localizePath", () => { it("should return the same path if the path is already localized", () => { const event = createEvent({ - url: "/fr/foo", + url: "http://localhost/fr/foo", }); const result = localizePath(event); @@ -95,7 +96,7 @@ describe("localizePath", () => { it("should get locale from cookies if NEXT_LOCALE cookie is set to a valid locale", () => { const event = createEvent({ - url: "/foo", + url: "http://localhost/foo", cookies: { NEXT_LOCALE: "fr", }, @@ -108,7 +109,7 @@ describe("localizePath", () => { it("should fallback on default locale if NEXT_LOCALE cookie is set to an invalid locale", () => { const event = createEvent({ - url: "/foo", + url: "http://localhost/foo", cookies: { NEXT_LOCALE: "pt", }, @@ -121,7 +122,7 @@ describe("localizePath", () => { it("should use accept-language header if no cookie are present", () => { const event = createEvent({ - url: "/foo", + url: "http://localhost/foo", headers: { "accept-language": "fr", }, @@ -134,7 +135,7 @@ describe("localizePath", () => { it("should fallback to default locale if no cookie or header are set", () => { const event = createEvent({ - url: "/foo", + url: "http://localhost/foo", }); const result = localizePath(event); @@ -142,3 +143,209 @@ describe("localizePath", () => { expect(result).toEqual("/en/foo"); }); }); + +describe("handleLocaleRedirect", () => { + it("should redirect to the localized path if the path is not localized", () => { + const event = createEvent({ + url: "http://localhost", + headers: { + "accept-language": "fr", + }, + }); + + const result = handleLocaleRedirect(event); + + expect(result).toMatchObject({ + statusCode: 307, + headers: { + Location: "http://localhost/fr", + }, + }); + }); + + it("should not redirect if the path is already localized", () => { + const event = createEvent({ + url: "http://localhost/fr", + }); + + const result = handleLocaleRedirect(event); + + expect(result).toBe(false); + }); + + it("should not redirect if not the root path", () => { + const event = createEvent({ + url: "http://localhost/foo", + }); + + const result = handleLocaleRedirect(event); + + expect(result).toBe(false); + }); + + describe("using domain", () => { + it("should redirect to the preferred domain if the domain is different", () => { + vi.spyOn(NextConfig, "i18n", "get").mockReturnValue({ + defaultLocale: "en", + locales: ["en", "en-US", "en-CA", "fr"], + domains: [ + { + domain: "mydomain.com", + defaultLocale: "en", + }, + { + domain: "localhost", + defaultLocale: "fr", + http: true, + }, + ], + }); + const event = createEvent({ + url: "http://mydomain.com", + headers: { + host: "mydomain.com", + "accept-language": "fr", + }, + }); + + const result = handleLocaleRedirect(event); + + expect(result).toMatchObject({ + statusCode: 307, + headers: { + Location: "http://localhost/", + }, + }); + }); + + it("should redirect to the same domain with not default locale", () => { + vi.spyOn(NextConfig, "i18n", "get").mockReturnValue({ + defaultLocale: "en", + locales: ["en", "fr", "fr-FR"], + domains: [ + { + domain: "mydomain.com", + defaultLocale: "en", + }, + { + domain: "localhost", + defaultLocale: "fr", + locales: ["fr-FR"], + http: true, + }, + ], + }); + const event = createEvent({ + url: "http://localhost", + headers: { + host: "localhost", + "accept-language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + }); + + const result = handleLocaleRedirect(event); + + expect(result).toMatchObject({ + statusCode: 307, + headers: { + Location: "http://localhost/fr-FR", + }, + }); + }); + + it("should redirect to different domain with not default locale", () => { + vi.spyOn(NextConfig, "i18n", "get").mockReturnValue({ + defaultLocale: "en", + locales: ["en", "fr", "fr-FR"], + domains: [ + { + domain: "mydomain.com", + defaultLocale: "en", + }, + { + domain: "localhost", + defaultLocale: "fr", + locales: ["fr-FR"], + http: true, + }, + ], + }); + const event = createEvent({ + url: "http://mydomain.com", + headers: { + host: "mydomain.com", + "accept-language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + }); + + const result = handleLocaleRedirect(event); + + expect(result).toMatchObject({ + statusCode: 307, + headers: { + Location: "http://localhost/fr-FR", + }, + }); + }); + + it("should not redirect if the domain and locale are the same", () => { + vi.spyOn(NextConfig, "i18n", "get").mockReturnValue({ + defaultLocale: "en", + locales: ["en", "fr", "fr-FR"], + domains: [ + { + domain: "mydomain.com", + defaultLocale: "en", + }, + { + domain: "localhost", + defaultLocale: "fr", + locales: ["fr-FR"], + http: true, + }, + ], + }); + const event = createEvent({ + url: "http://localhost/fr-FR", + headers: { + host: "localhost", + "accept-language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + }); + + const result = handleLocaleRedirect(event); + + expect(result).toBe(false); + }); + + it("should not redirect if locale is not found", () => { + vi.spyOn(NextConfig, "i18n", "get").mockReturnValue({ + defaultLocale: "en", + locales: ["en", "fr", "fr-FR"], + domains: [ + { + domain: "mydomain.com", + defaultLocale: "en", + }, + { + domain: "localhost", + defaultLocale: "fr", + locales: ["fr-FR"], + http: true, + }, + ], + }); + const event = createEvent({ + url: "http://localhost", + headers: { + host: "localhost", + "accept-language": "es", + }, + }); + + const result = handleLocaleRedirect(event); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/matcher.test.ts b/packages/tests-unit/tests/core/routing/matcher.test.ts index 0876215ea..680320a12 100644 --- a/packages/tests-unit/tests/core/routing/matcher.test.ts +++ b/packages/tests-unit/tests/core/routing/matcher.test.ts @@ -14,6 +14,7 @@ vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ })); vi.mock("@opennextjs/aws/core/routing/i18n/index.js", () => ({ localizePath: (event: InternalEvent) => event.rawPath, + handleLocaleRedirect: (_event: InternalEvent) => false, })); type PartialEvent = Partial< diff --git a/packages/tests-unit/tests/core/routing/util.test.ts b/packages/tests-unit/tests/core/routing/util.test.ts index b8e4bfd94..b3d721b56 100644 --- a/packages/tests-unit/tests/core/routing/util.test.ts +++ b/packages/tests-unit/tests/core/routing/util.test.ts @@ -1,7 +1,9 @@ /* eslint-disable sonarjs/no-duplicate-string */ import * as config from "@opennextjs/aws/adapters/config/index.js"; +import { NextConfig } from "@opennextjs/aws/adapters/config/index.js"; import { addOpenNextHeader, + constructNextUrl, convertBodyToReadableStream, convertFromQueryString, convertRes, @@ -22,7 +24,9 @@ import { fromReadableStream } from "@opennextjs/aws/utils/stream.js"; import { vi } from "vitest"; vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ - NextConfig: {}, + NextConfig: { + basePath: "", + }, HtmlPages: [], })); @@ -815,3 +819,16 @@ describe("invalidateCDNOnRequest", () => { ]); }); }); + +describe("constructNextUrl", () => { + it("should construct next url without a basePath", () => { + const result = constructNextUrl("http://localhost", "/path"); + expect(result).toBe("http://localhost/path"); + }); + + it("should construct next url with a basePath", () => { + vi.spyOn(NextConfig, "basePath", "get").mockReturnValue("/base"); + const result = constructNextUrl("http://localhost", "/path"); + expect(result).toBe("http://localhost/base/path"); + }); +});