Skip to content

feat(@angular/ssr): redirect to preferred locale when accessing root route without a specified locale #29044

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,30 @@ export function generateAngularServerAppEngineManifest(
baseHref: string | undefined,
): string {
const entryPoints: Record<string, string> = {};
const supportedLocales: Record<string, string> = {};

if (i18nOptions.shouldInline && !i18nOptions.flatOutput) {
for (const locale of i18nOptions.inlineLocales) {
const { subPath } = i18nOptions.locales[locale];
const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`;
entryPoints[subPath] = `() => import('./${importPath}')`;
supportedLocales[locale] = subPath;
}
} else {
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;
supportedLocales[i18nOptions.sourceLocale] = '';
}

// Remove trailing slash but retain leading slash.
let basePath = baseHref || '/';
if (basePath.length > 1 && basePath[basePath.length - 1] === '/') {
basePath = basePath.slice(0, -1);
}

const manifestContent = `
export default {
basePath: '${baseHref ?? '/'}',
basePath: '${basePath}',
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
entryPoints: {
${Object.entries(entryPoints)
.map(([key, value]) => `'${key}': ${value}`)
Expand Down
64 changes: 59 additions & 5 deletions packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

import type { AngularServerApp, getOrCreateAngularServerApp } from './app';
import { Hooks } from './hooks';
import { getPotentialLocaleIdFromUrl } from './i18n';
import { getPotentialLocaleIdFromUrl, getPreferredLocale } from './i18n';
import { EntryPointExports, getAngularAppEngineManifest } from './manifest';
import { joinUrlParts } from './utils/url';

/**
* Angular server application engine.
Expand Down Expand Up @@ -47,9 +48,11 @@ export class AngularAppEngine {
private readonly manifest = getAngularAppEngineManifest();

/**
* The number of entry points available in the server application's manifest.
* A map of supported locales from the server application's manifest.
*/
private readonly entryPointsCount = Object.keys(this.manifest.entryPoints).length;
private readonly supportedLocales: ReadonlyArray<string> = Object.keys(
this.manifest.supportedLocales,
);

/**
* A cache that holds entry points, keyed by their potential locale string.
Expand All @@ -70,7 +73,58 @@ export class AngularAppEngine {
async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
const serverApp = await this.getAngularServerAppForRequest(request);

return serverApp ? serverApp.handle(request, requestContext) : null;
if (serverApp) {
return serverApp.handle(request, requestContext);
}

if (this.supportedLocales.length > 1) {
// Redirect to the preferred language if i18n is enabled.
return this.redirectBasedOnAcceptLanguage(request);
}

return null;
}

/**
* Handles requests for the base path when i18n is enabled.
* Redirects the user to a locale-specific path based on the `Accept-Language` header.
*
* @param request The incoming request.
* @returns A `Response` object with a 302 redirect, or `null` if i18n is not enabled
* or the request is not for the base path.
*/
private redirectBasedOnAcceptLanguage(request: Request): Response | null {
const { basePath, supportedLocales } = this.manifest;

// If the request is not for the base path, it's not our responsibility to handle it.
const url = new URL(request.url);
if (url.pathname !== basePath) {
return null;
}

// For requests to the base path (typically '/'), attempt to extract the preferred locale
// from the 'Accept-Language' header.
const preferredLocale = getPreferredLocale(
request.headers.get('Accept-Language') || '*',
this.supportedLocales,
);

if (preferredLocale) {
const subPath = supportedLocales[preferredLocale];
if (subPath !== undefined) {
url.pathname = joinUrlParts(url.pathname, subPath);

return new Response(null, {
status: 302, // Use a 302 redirect as language preference may change.
headers: {
'Location': url.toString(),
'Vary': 'Accept-Language',
},
});
}
}

return null;
}

/**
Expand Down Expand Up @@ -142,7 +196,7 @@ export class AngularAppEngine {
*/
private getEntryPointExportsForUrl(url: URL): Promise<EntryPointExports> | undefined {
const { basePath } = this.manifest;
if (this.entryPointsCount === 1) {
if (this.supportedLocales.length === 1) {
return this.getEntryPointExports('');
}

Expand Down
157 changes: 157 additions & 0 deletions packages/angular/ssr/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,160 @@ export function getPotentialLocaleIdFromUrl(url: URL, basePath: string): string
// Extract the potential locale id.
return pathname.slice(start, end);
}

/**
* Parses the `Accept-Language` header and returns a list of locale preferences with their respective quality values.
*
* The `Accept-Language` header is typically a comma-separated list of locales, with optional quality values
* in the form of `q=<value>`. If no quality value is specified, a default quality of `1` is assumed.
* Special case: if the header is `*`, it returns the default locale with a quality of `1`.
*
* @param header - The value of the `Accept-Language` header, typically a comma-separated list of locales
* with optional quality values (e.g., `en-US;q=0.8,fr-FR;q=0.9`). If the header is `*`,
* it represents a wildcard for any language, returning the default locale.
*
* @returns A `ReadonlyMap` where the key is the locale (e.g., `en-US`, `fr-FR`), and the value is
* the associated quality value (a number between 0 and 1). If no quality value is provided,
* a default of `1` is used.
*
* @example
* ```js
* parseLanguageHeader('en-US;q=0.8,fr-FR;q=0.9')
* // returns new Map([['en-US', 0.8], ['fr-FR', 0.9]])

* parseLanguageHeader('*')
* // returns new Map([['*', 1]])
* ```
*/
function parseLanguageHeader(header: string): ReadonlyMap<string, number> {
if (header === '*') {
return new Map([['*', 1]]);
}

const parsedValues = header
.split(',')
.map((item) => {
const [locale, qualityValue] = item.split(';', 2).map((v) => v.trim());

let quality = qualityValue?.startsWith('q=') ? parseFloat(qualityValue.slice(2)) : undefined;
if (typeof quality !== 'number' || isNaN(quality) || quality < 0 || quality > 1) {
quality = 1; // Invalid quality value defaults to 1
}

return [locale, quality] as const;
})
.sort(([_localeA, qualityA], [_localeB, qualityB]) => qualityB - qualityA);

return new Map(parsedValues);
}

/**
* Gets the preferred locale based on the highest quality value from the provided `Accept-Language` header
* and the set of available locales.
*
* This function adheres to the HTTP `Accept-Language` header specification as defined in
* [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5), including:
* - Case-insensitive matching of language tags.
* - Quality value handling (e.g., `q=1`, `q=0.8`). If no quality value is provided, it defaults to `q=1`.
* - Prefix matching (e.g., `en` matching `en-US` or `en-GB`).
*
* @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
* locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
* @param supportedLocales - An array of supported locales (e.g., `['en-US', 'fr-FR']`),
* representing the locales available in the application.
* @returns The best matching locale from the supported languages, or `undefined` if no match is found.
*
* @example
* ```js
* getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', ['en-US', 'fr-FR', 'de-DE'])
* // returns 'fr-FR'
*
* getPreferredLocale('en;q=0.9,fr-FR;q=0.8', ['en-US', 'fr-FR', 'de-DE'])
* // returns 'en-US'
*
* getPreferredLocale('es-ES;q=0.7', ['en-US', 'fr-FR', 'de-DE'])
* // returns undefined
* ```
*/
export function getPreferredLocale(
header: string,
supportedLocales: ReadonlyArray<string>,
): string | undefined {
if (supportedLocales.length < 2) {
return supportedLocales[0];
}

const parsedLocales = parseLanguageHeader(header);

// Handle edge cases:
// - No preferred locales provided.
// - Only one supported locale.
// - Wildcard preference.
if (parsedLocales.size === 0 || (parsedLocales.size === 1 && parsedLocales.has('*'))) {
return supportedLocales[0];
}

// Create a map for case-insensitive lookup of supported locales.
// Keys are normalized (lowercase) locale values, values are original casing.
const normalizedSupportedLocales = new Map<string, string>();
for (const locale of supportedLocales) {
normalizedSupportedLocales.set(normalizeLocale(locale), locale);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf: Could we normalize the locales directly in the manifest? Why do we need to repeat this for each request?

If we can do this in the manifest, I definitely recommend defining a normalizeLocale function which does locale.toLowerCase() then call that for both the supported locales in the manifest as well as the Accept-Language locales. Having a single function normalize both makes it less likely they fall out of sync in the future and make it easier to track usage across the repo.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do this, but the improvement is so minimal that it doesn’t seem worthwhile.

For example, even in an extreme case where users have 200 locales and perform this operation over 1000 iterations, it only takes 0.018 ms.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do this, but the improvement is so minimal that it doesn’t seem worthwhile.

For example, even in an extreme case where users have 200 locales and perform this operation over 1000 iterations, it only takes 0.018 ms.


// Iterate through parsed locales in descending order of quality.
let bestMatch: string | undefined;
const qualityZeroNormalizedLocales = new Set<string>();
for (const [locale, quality] of parsedLocales) {
const normalizedLocale = normalizeLocale(locale);
if (quality === 0) {
qualityZeroNormalizedLocales.add(normalizedLocale);
continue; // Skip locales with quality value of 0.
}

// Exact match found.
if (normalizedSupportedLocales.has(normalizedLocale)) {
return normalizedSupportedLocales.get(normalizedLocale);
}

// If an exact match is not found, try prefix matching (e.g., "en" matches "en-US").
// Store the first prefix match encountered, as it has the highest quality value.
if (bestMatch !== undefined) {
continue;
}

const [languagePrefix] = normalizedLocale.split('-', 1);
for (const supportedLocale of normalizedSupportedLocales.keys()) {
if (supportedLocale.startsWith(languagePrefix)) {
bestMatch = normalizedSupportedLocales.get(supportedLocale);
break; // No need to continue searching for this locale.
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf: This is a O(parsedLocales.length * normalizedSupportedLocales.size) check. I wonder if we could do something slightly more optimal by using a tree instead of a map, where each node in the tree is a locale segment. Such that you could have:

en -> US
   |
    > GB
it
es

Then looking up a locale is just walking the tree and if we don't find it, we use the deepest result we found which would inherently be the most specific locale.

If we limit this implementation to just two locale segments, it's probably not that important and likely fine to ignore for now. If we wanted to generically support N locale segments, it might be a useful optimization.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not overly concerned about performance in this case, as the number of parsedLocales and normalizedSupportedLocales is usually quite small.

We could definitely consider this in the future.

}

if (bestMatch !== undefined) {
return bestMatch;
}

// Return the first locale that is not quality zero.
for (const [normalizedLocale, locale] of normalizedSupportedLocales) {
if (!qualityZeroNormalizedLocales.has(normalizedLocale)) {
return locale;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: I've never really understood, what exactly are the semantics of q=0 anyways? Why would a browser send that? How is it different from just not sending a locale?

Based on this implementation, it seems like we're interpreting that to mean "I do not want this locale" and we therefore pick any other locale the user didn't explicitly reject. Is that the intended usage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, a quality value of 0 means the locale is explicitly not acceptable.

}

/**
* Normalizes a locale string by converting it to lowercase.
*
* @param locale - The locale string to normalize.
* @returns The normalized locale string in lowercase.
*
* @example
* ```ts
* const normalized = normalizeLocale('EN-US');
* console.log(normalized); // Output: "en-us"
* ```
*/
function normalizeLocale(locale: string): string {
return locale.toLowerCase();
}
10 changes: 9 additions & 1 deletion packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface AngularAppEngineManifest {
/**
* A readonly record of entry points for the server application.
* Each entry consists of:
* - `key`: The base href for the entry point.
* - `key`: The url segment for the entry point.
* - `value`: A function that returns a promise resolving to an object of type `EntryPointExports`.
*/
readonly entryPoints: Readonly<Record<string, (() => Promise<EntryPointExports>) | undefined>>;
Expand All @@ -65,6 +65,14 @@ export interface AngularAppEngineManifest {
* This is used to determine the root path of the application.
*/
readonly basePath: string;

/**
* A readonly record mapping supported locales to their respective entry-point paths.
* Each entry consists of:
* - `key`: The locale identifier (e.g., 'en', 'fr').
* - `value`: The url segment associated with that locale.
*/
readonly supportedLocales: Readonly<Record<string, string | undefined>>;
}

/**
Expand Down
38 changes: 36 additions & 2 deletions packages/angular/ssr/test/app-engine_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,32 @@ function createEntryPoint(locale: string) {
class SSGComponent {}

return async () => {
@Component({
standalone: true,
selector: `app-home-${locale}`,
template: `Home works ${locale.toUpperCase()}`,
})
class HomeComponent {}

@Component({
standalone: true,
selector: `app-ssr-${locale}`,
template: `SSR works ${locale.toUpperCase()}`,
})
class SSRComponent {}

@Component({
standalone: true,
selector: `app-ssg-${locale}`,
template: `SSG works ${locale.toUpperCase()}`,
})
class SSGComponent {}

setAngularAppTestingManifest(
[
{ path: 'ssg', component: SSGComponent },
{ path: 'ssr', component: SSRComponent },
{ path: '', component: HomeComponent },
],
[
{ path: 'ssg', renderMode: RenderMode.Prerender },
Expand Down Expand Up @@ -82,7 +104,8 @@ describe('AngularAppEngine', () => {
it: createEntryPoint('it'),
en: createEntryPoint('en'),
},
basePath: '',
supportedLocales: { 'it': 'it', 'en': 'en' },
basePath: '/',
});

appEngine = new AngularAppEngine();
Expand Down Expand Up @@ -133,6 +156,16 @@ describe('AngularAppEngine', () => {
expect(response).toBeNull();
});

it('should redirect to the highest priority locale when the URL is "/"', async () => {
const request = new Request('https://example.com/', {
headers: { 'Accept-Language': 'fr-CH, fr;q=0.9, it;q=0.8, en;q=0.7, *;q=0.5' },
});
const response = await appEngine.handle(request);
expect(response?.status).toBe(302);
expect(response?.headers.get('Location')).toBe('https://example.com/it');
expect(response?.headers.get('Vary')).toBe('Accept-Language');
});

it('should return null for requests to file-like resources in a locale', async () => {
const request = new Request('https://example.com/it/logo.png');
const response = await appEngine.handle(request);
Expand Down Expand Up @@ -164,7 +197,8 @@ describe('AngularAppEngine', () => {
};
},
},
basePath: '',
basePath: '/',
supportedLocales: { 'en-US': '' },
});

appEngine = new AngularAppEngine();
Expand Down
Loading
Loading