@angular/ssr 19.1.0-next.0 → 19.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fesm2022/ssr.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
2
- import { ɵConsole as _Console, InjectionToken, makeEnvironmentProviders, runInInjectionContext, ApplicationRef, Compiler, REQUEST, REQUEST_CONTEXT, RESPONSE_INIT, LOCALE_ID, ɵresetCompiledComponents as _resetCompiledComponents } from '@angular/core';
2
+ import { ɵConsole as _Console, InjectionToken, makeEnvironmentProviders, runInInjectionContext, APP_INITIALIZER, inject, ApplicationRef, Compiler, REQUEST, REQUEST_CONTEXT, RESPONSE_INIT, LOCALE_ID, ɵresetCompiledComponents as _resetCompiledComponents } from '@angular/core';
3
3
  import { ɵSERVER_CONTEXT as _SERVER_CONTEXT, renderModule, renderApplication, platformServer, INITIAL_CONFIG } from '@angular/platform-server';
4
4
  import { ɵloadChildren as _loadChildren, Router } from '@angular/router';
5
5
  import Beasties from '../third_party/beasties/index.js';
@@ -51,6 +51,10 @@ class ServerAssets {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * A set of log messages that should be ignored and not printed to the console.
56
+ */
57
+ const IGNORED_LOGS = new Set(['Angular is running in development mode.']);
54
58
  /**
55
59
  * Custom implementation of the Angular Console service that filters out specific log messages.
56
60
  *
@@ -58,21 +62,17 @@ class ServerAssets {
58
62
  * It overrides the `log` method to suppress logs that match certain predefined messages.
59
63
  */
60
64
  class Console extends _Console {
61
- /**
62
- * A set of log messages that should be ignored and not printed to the console.
63
- */
64
- ignoredLogs = new Set(['Angular is running in development mode.']);
65
65
  /**
66
66
  * Logs a message to the console if it is not in the set of ignored messages.
67
67
  *
68
68
  * @param message - The message to log to the console.
69
69
  *
70
70
  * This method overrides the `log` method of the `ɵConsole` class. It checks if the
71
- * message is in the `ignoredLogs` set. If it is not, it delegates the logging to
71
+ * message is in the `IGNORED_LOGS` set. If it is not, it delegates the logging to
72
72
  * the parent class's `log` method. Otherwise, the message is suppressed.
73
73
  */
74
74
  log(message) {
75
- if (!this.ignoredLogs.has(message)) {
75
+ if (!IGNORED_LOGS.has(message)) {
76
76
  super.log(message);
77
77
  }
78
78
  }
@@ -454,8 +454,6 @@ const SERVER_ROUTES_CONFIG = new InjectionToken('SERVER_ROUTES_CONFIG');
454
454
  * @param options - (Optional) An object containing additional configuration options for server routes.
455
455
  * @returns An `EnvironmentProviders` instance with the server routes configuration.
456
456
  *
457
- * @returns An `EnvironmentProviders` object that contains the server routes configuration.
458
- *
459
457
  * @see {@link ServerRoute}
460
458
  * @see {@link ServerRoutesConfigOptions}
461
459
  * @developerPreview
@@ -657,6 +655,11 @@ class RouteTree {
657
655
  }
658
656
  }
659
657
 
658
+ /**
659
+ * The maximum number of module preload link elements that should be added for
660
+ * initial scripts.
661
+ */
662
+ const MODULE_PRELOAD_MAX = 10;
660
663
  /**
661
664
  * Regular expression to match segments preceded by a colon in a string.
662
665
  */
@@ -675,10 +678,10 @@ const VALID_REDIRECT_RESPONSE_CODES = new Set([301, 302, 303, 307, 308]);
675
678
  * @returns An async iterable iterator yielding either route tree node metadata or an error object with an error message.
676
679
  */
677
680
  async function* traverseRoutesConfig(options) {
678
- const { routes, compiler, parentInjector, parentRoute, serverConfigRouteTree, invokeGetPrerenderParams, includePrerenderFallbackRoutes, } = options;
681
+ const { routes, compiler, parentInjector, parentRoute, serverConfigRouteTree, entryPointToBrowserMapping, parentPreloads, invokeGetPrerenderParams, includePrerenderFallbackRoutes, } = options;
679
682
  for (const route of routes) {
680
683
  try {
681
- const { path = '', redirectTo, loadChildren, children } = route;
684
+ const { path = '', redirectTo, loadChildren, loadComponent, children, ɵentryName } = route;
682
685
  const currentRoutePath = joinUrlParts(parentRoute, path);
683
686
  // Get route metadata from the server config route tree, if available
684
687
  let matchedMetaData;
@@ -696,12 +699,16 @@ async function* traverseRoutesConfig(options) {
696
699
  const metadata = {
697
700
  renderMode: RenderMode.Prerender,
698
701
  ...matchedMetaData,
702
+ preload: parentPreloads,
699
703
  // Match Angular router behavior
700
704
  // ['one', 'two', ''] -> 'one/two/'
701
705
  // ['one', 'two', 'three'] -> 'one/two/three'
702
706
  route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath,
707
+ presentInClientRouter: undefined,
703
708
  };
704
- delete metadata.presentInClientRouter;
709
+ if (ɵentryName && loadComponent) {
710
+ appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
711
+ }
705
712
  if (metadata.renderMode === RenderMode.Prerender) {
706
713
  // Handle SSG routes
707
714
  yield* handleSSGRoute(typeof redirectTo === 'string' ? redirectTo : undefined, metadata, parentInjector, invokeGetPrerenderParams, includePrerenderFallbackRoutes);
@@ -726,10 +733,18 @@ async function* traverseRoutesConfig(options) {
726
733
  ...options,
727
734
  routes: children,
728
735
  parentRoute: currentRoutePath,
736
+ parentPreloads: metadata.preload,
729
737
  });
730
738
  }
731
739
  // Load and process lazy-loaded child routes
732
740
  if (loadChildren) {
741
+ if (ɵentryName) {
742
+ // When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
743
+ // As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
744
+ // across different child routes. In contrast, `loadComponent` only loads a single component, which allows
745
+ // for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
746
+ appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
747
+ }
733
748
  const loadedChildRoutes = await _loadChildren(route, compiler, parentInjector).toPromise();
734
749
  if (loadedChildRoutes) {
735
750
  const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
@@ -738,6 +753,7 @@ async function* traverseRoutesConfig(options) {
738
753
  routes: childRoutes,
739
754
  parentInjector: injector,
740
755
  parentRoute: currentRoutePath,
756
+ parentPreloads: metadata.preload,
741
757
  });
742
758
  }
743
759
  }
@@ -749,6 +765,27 @@ async function* traverseRoutesConfig(options) {
749
765
  }
750
766
  }
751
767
  }
768
+ /**
769
+ * Appends preload information to the metadata object based on the specified entry-point and chunk mappings.
770
+ *
771
+ * This function extracts preload data for a given entry-point from the provided chunk mappings. It adds the
772
+ * corresponding browser bundles to the metadata's preload list, ensuring no duplicates and limiting the total
773
+ * preloads to a predefined maximum.
774
+ */
775
+ function appendPreloadToMetadata(entryName, entryPointToBrowserMapping, metadata, includeDynamicImports) {
776
+ if (!entryPointToBrowserMapping) {
777
+ return;
778
+ }
779
+ const preload = entryPointToBrowserMapping[entryName];
780
+ if (preload?.length) {
781
+ // Merge existing preloads with new ones, ensuring uniqueness and limiting the total to the maximum allowed.
782
+ const preloadPaths = preload
783
+ .filter(({ dynamicImport }) => includeDynamicImports || !dynamicImport)
784
+ .map(({ path }) => path) ?? [];
785
+ const combinedPreloads = [...(metadata.preload ?? []), ...preloadPaths];
786
+ metadata.preload = Array.from(new Set(combinedPreloads)).slice(0, MODULE_PRELOAD_MAX);
787
+ }
788
+ }
752
789
  /**
753
790
  * Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
754
791
  * all parameterized paths, returning any errors encountered.
@@ -893,10 +930,11 @@ function buildServerConfigRouteTree({ routes, appShellRoute }) {
893
930
  * @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes
894
931
  * to handle prerendering paths. Defaults to `false`.
895
932
  * @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result. Defaults to `true`.
933
+ * @param entryPointToBrowserMapping - Maps the entry-point name to the associated JavaScript browser bundles.
896
934
  *
897
935
  * @returns A promise that resolves to an object of type `AngularRouterConfigResult` or errors.
898
936
  */
899
- async function getRoutesFromAngularRouterConfig(bootstrap, document, url, invokeGetPrerenderParams = false, includePrerenderFallbackRoutes = true) {
937
+ async function getRoutesFromAngularRouterConfig(bootstrap, document, url, invokeGetPrerenderParams = false, includePrerenderFallbackRoutes = true, entryPointToBrowserMapping = undefined) {
900
938
  const { protocol, host } = url;
901
939
  // Create and initialize the Angular platform for server-side rendering.
902
940
  const platformRef = platformServer([
@@ -905,9 +943,25 @@ async function getRoutesFromAngularRouterConfig(bootstrap, document, url, invoke
905
943
  useValue: { document, url: `${protocol}//${host}/` },
906
944
  },
907
945
  {
946
+ // An Angular Console Provider that does not print a set of predefined logs.
908
947
  provide: _Console,
948
+ // Using `useClass` would necessitate decorating `Console` with `@Injectable`,
949
+ // which would require switching from `ts_library` to `ng_module`. This change
950
+ // would also necessitate various patches of `@angular/bazel` to support ESM.
909
951
  useFactory: () => new Console(),
910
952
  },
953
+ {
954
+ // We cannot replace `ApplicationRef` with a different provider here due to the dependency injection (DI) hierarchy.
955
+ // This code is running at the platform level, where `ApplicationRef` is provided in the root injector.
956
+ // As a result, any attempt to replace it will cause the root provider to override the platform provider.
957
+ // TODO(alanagius): investigate exporting the app config directly which would help with: https://github.com/angular/angular/issues/59144
958
+ provide: APP_INITIALIZER,
959
+ multi: true,
960
+ useFactory: () => () => {
961
+ const appRef = inject(ApplicationRef);
962
+ appRef.bootstrap = () => undefined;
963
+ },
964
+ },
911
965
  ]);
912
966
  try {
913
967
  let applicationRef;
@@ -954,6 +1008,7 @@ async function getRoutesFromAngularRouterConfig(bootstrap, document, url, invoke
954
1008
  serverConfigRouteTree,
955
1009
  invokeGetPrerenderParams,
956
1010
  includePrerenderFallbackRoutes,
1011
+ entryPointToBrowserMapping,
957
1012
  });
958
1013
  for await (const result of traverseRoutes) {
959
1014
  if ('error' in result) {
@@ -1026,7 +1081,7 @@ function extractRoutesAndCreateRouteTree(options) {
1026
1081
  const routeTree = new RouteTree();
1027
1082
  const document = await new ServerAssets(manifest).getIndexServerHtml().text();
1028
1083
  const bootstrap = await manifest.bootstrap();
1029
- const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(bootstrap, document, url, invokeGetPrerenderParams, includePrerenderFallbackRoutes);
1084
+ const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(bootstrap, document, url, invokeGetPrerenderParams, includePrerenderFallbackRoutes, manifest.entryPointToBrowserMapping);
1030
1085
  for (const { route, ...metadata } of routes) {
1031
1086
  if (metadata.redirectTo !== undefined) {
1032
1087
  metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo);
@@ -1648,10 +1703,11 @@ class AngularServerApp {
1648
1703
  return null;
1649
1704
  }
1650
1705
  const assetPath = this.buildServerAssetPathFromRequest(request);
1651
- if (!this.assets.hasServerAsset(assetPath)) {
1706
+ const { manifest: { locale }, assets, } = this;
1707
+ if (!assets.hasServerAsset(assetPath)) {
1652
1708
  return null;
1653
1709
  }
1654
- const { text, hash, size } = this.assets.getServerAsset(assetPath);
1710
+ const { text, hash, size } = assets.getServerAsset(assetPath);
1655
1711
  const etag = `"${hash}"`;
1656
1712
  return request.headers.get('if-none-match') === etag
1657
1713
  ? new Response(undefined, { status: 304, statusText: 'Not Modified' })
@@ -1660,6 +1716,7 @@ class AngularServerApp {
1660
1716
  'Content-Length': size.toString(),
1661
1717
  'ETag': etag,
1662
1718
  'Content-Type': 'text/html;charset=UTF-8',
1719
+ ...(locale !== undefined ? { 'Content-Language': locale } : {}),
1663
1720
  ...headers,
1664
1721
  },
1665
1722
  });
@@ -1676,17 +1733,19 @@ class AngularServerApp {
1676
1733
  * @returns A promise that resolves to the rendered response, or null if no matching route is found.
1677
1734
  */
1678
1735
  async handleRendering(request, matchedRoute, requestContext) {
1679
- const { renderMode, headers, status } = matchedRoute;
1736
+ const { renderMode, headers, status, preload } = matchedRoute;
1680
1737
  if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
1681
1738
  return null;
1682
1739
  }
1683
1740
  const url = new URL(request.url);
1684
1741
  const platformProviders = [];
1742
+ const { manifest: { bootstrap, inlineCriticalCss, locale }, assets, } = this;
1685
1743
  // Initialize the response with status and headers if available.
1686
1744
  const responseInit = {
1687
1745
  status,
1688
1746
  headers: new Headers({
1689
1747
  'Content-Type': 'text/html;charset=UTF-8',
1748
+ ...(locale !== undefined ? { 'Content-Language': locale } : {}),
1690
1749
  ...headers,
1691
1750
  }),
1692
1751
  };
@@ -1706,10 +1765,9 @@ class AngularServerApp {
1706
1765
  else if (renderMode === RenderMode.Client) {
1707
1766
  // Serve the client-side rendered version if the route is configured for CSR.
1708
1767
  let html = await this.assets.getServerAsset('index.csr.html').text();
1709
- html = await this.runTransformsOnHtml(html, url);
1768
+ html = await this.runTransformsOnHtml(html, url, preload);
1710
1769
  return new Response(html, responseInit);
1711
1770
  }
1712
- const { manifest: { bootstrap, inlineCriticalCss, locale }, hooks, assets, } = this;
1713
1771
  if (locale !== undefined) {
1714
1772
  platformProviders.push({
1715
1773
  provide: LOCALE_ID,
@@ -1718,7 +1776,7 @@ class AngularServerApp {
1718
1776
  }
1719
1777
  this.boostrap ??= await bootstrap();
1720
1778
  let html = await assets.getIndexServerHtml().text();
1721
- html = await this.runTransformsOnHtml(html, url);
1779
+ html = await this.runTransformsOnHtml(html, url, preload);
1722
1780
  html = await renderAngular(html, this.boostrap, url, platformProviders, SERVER_CONTEXT_VALUE[renderMode]);
1723
1781
  if (inlineCriticalCss) {
1724
1782
  // Optionally inline critical CSS.
@@ -1778,12 +1836,16 @@ class AngularServerApp {
1778
1836
  *
1779
1837
  * @param html - The raw HTML content to be transformed.
1780
1838
  * @param url - The URL associated with the HTML content, used for context during transformations.
1839
+ * @param preload - An array of URLs representing the JavaScript resources to preload.
1781
1840
  * @returns A promise that resolves to the transformed HTML string.
1782
1841
  */
1783
- async runTransformsOnHtml(html, url) {
1842
+ async runTransformsOnHtml(html, url, preload) {
1784
1843
  if (this.hooks.has('html:transform:pre')) {
1785
1844
  html = await this.hooks.run('html:transform:pre', { html, url });
1786
1845
  }
1846
+ if (preload?.length) {
1847
+ html = appendPreloadHintsToHtml(html, preload);
1848
+ }
1787
1849
  return html;
1788
1850
  }
1789
1851
  }
@@ -1816,6 +1878,31 @@ function destroyAngularServerApp() {
1816
1878
  }
1817
1879
  angularServerApp = undefined;
1818
1880
  }
1881
+ /**
1882
+ * Appends module preload hints to an HTML string for specified JavaScript resources.
1883
+ * This function enhances the HTML by injecting `<link rel="modulepreload">` elements
1884
+ * for each provided resource, allowing browsers to preload the specified JavaScript
1885
+ * modules for better performance.
1886
+ *
1887
+ * @param html - The original HTML string to which preload hints will be added.
1888
+ * @param preload - An array of URLs representing the JavaScript resources to preload.
1889
+ * @returns The modified HTML string with the preload hints injected before the closing `</body>` tag.
1890
+ * If `</body>` is not found, the links are not added.
1891
+ */
1892
+ function appendPreloadHintsToHtml(html, preload) {
1893
+ const bodyCloseIdx = html.lastIndexOf('</body>');
1894
+ if (bodyCloseIdx === -1) {
1895
+ return html;
1896
+ }
1897
+ // Note: Module preloads should be placed at the end before the closing body tag to avoid a performance penalty.
1898
+ // Placing them earlier can cause the browser to prioritize downloading these modules
1899
+ // over other critical page resources like images, CSS, and fonts.
1900
+ return [
1901
+ html.slice(0, bodyCloseIdx),
1902
+ ...preload.map((val) => `<link rel="modulepreload" href="${val}">`),
1903
+ html.slice(bodyCloseIdx),
1904
+ ].join('\n');
1905
+ }
1819
1906
 
1820
1907
  // ɵgetRoutesFromAngularRouterConfig is only used by the Webpack based server builder.
1821
1908
 
@@ -1853,6 +1940,144 @@ function getPotentialLocaleIdFromUrl(url, basePath) {
1853
1940
  // Extract the potential locale id.
1854
1941
  return pathname.slice(start, end);
1855
1942
  }
1943
+ /**
1944
+ * Parses the `Accept-Language` header and returns a list of locale preferences with their respective quality values.
1945
+ *
1946
+ * The `Accept-Language` header is typically a comma-separated list of locales, with optional quality values
1947
+ * in the form of `q=<value>`. If no quality value is specified, a default quality of `1` is assumed.
1948
+ * Special case: if the header is `*`, it returns the default locale with a quality of `1`.
1949
+ *
1950
+ * @param header - The value of the `Accept-Language` header, typically a comma-separated list of locales
1951
+ * with optional quality values (e.g., `en-US;q=0.8,fr-FR;q=0.9`). If the header is `*`,
1952
+ * it represents a wildcard for any language, returning the default locale.
1953
+ *
1954
+ * @returns A `ReadonlyMap` where the key is the locale (e.g., `en-US`, `fr-FR`), and the value is
1955
+ * the associated quality value (a number between 0 and 1). If no quality value is provided,
1956
+ * a default of `1` is used.
1957
+ *
1958
+ * @example
1959
+ * ```js
1960
+ * parseLanguageHeader('en-US;q=0.8,fr-FR;q=0.9')
1961
+ * // returns new Map([['en-US', 0.8], ['fr-FR', 0.9]])
1962
+
1963
+ * parseLanguageHeader('*')
1964
+ * // returns new Map([['*', 1]])
1965
+ * ```
1966
+ */
1967
+ function parseLanguageHeader(header) {
1968
+ if (header === '*') {
1969
+ return new Map([['*', 1]]);
1970
+ }
1971
+ const parsedValues = header
1972
+ .split(',')
1973
+ .map((item) => {
1974
+ const [locale, qualityValue] = item.split(';', 2).map((v) => v.trim());
1975
+ let quality = qualityValue?.startsWith('q=') ? parseFloat(qualityValue.slice(2)) : undefined;
1976
+ if (typeof quality !== 'number' || isNaN(quality) || quality < 0 || quality > 1) {
1977
+ quality = 1; // Invalid quality value defaults to 1
1978
+ }
1979
+ return [locale, quality];
1980
+ })
1981
+ .sort(([_localeA, qualityA], [_localeB, qualityB]) => qualityB - qualityA);
1982
+ return new Map(parsedValues);
1983
+ }
1984
+ /**
1985
+ * Gets the preferred locale based on the highest quality value from the provided `Accept-Language` header
1986
+ * and the set of available locales.
1987
+ *
1988
+ * This function adheres to the HTTP `Accept-Language` header specification as defined in
1989
+ * [RFC 7231](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5), including:
1990
+ * - Case-insensitive matching of language tags.
1991
+ * - Quality value handling (e.g., `q=1`, `q=0.8`). If no quality value is provided, it defaults to `q=1`.
1992
+ * - Prefix matching (e.g., `en` matching `en-US` or `en-GB`).
1993
+ *
1994
+ * @param header - The `Accept-Language` header string to parse and evaluate. It may contain multiple
1995
+ * locales with optional quality values, for example: `'en-US;q=0.8,fr-FR;q=0.9'`.
1996
+ * @param supportedLocales - An array of supported locales (e.g., `['en-US', 'fr-FR']`),
1997
+ * representing the locales available in the application.
1998
+ * @returns The best matching locale from the supported languages, or `undefined` if no match is found.
1999
+ *
2000
+ * @example
2001
+ * ```js
2002
+ * getPreferredLocale('en-US;q=0.8,fr-FR;q=0.9', ['en-US', 'fr-FR', 'de-DE'])
2003
+ * // returns 'fr-FR'
2004
+ *
2005
+ * getPreferredLocale('en;q=0.9,fr-FR;q=0.8', ['en-US', 'fr-FR', 'de-DE'])
2006
+ * // returns 'en-US'
2007
+ *
2008
+ * getPreferredLocale('es-ES;q=0.7', ['en-US', 'fr-FR', 'de-DE'])
2009
+ * // returns undefined
2010
+ * ```
2011
+ */
2012
+ function getPreferredLocale(header, supportedLocales) {
2013
+ if (supportedLocales.length < 2) {
2014
+ return supportedLocales[0];
2015
+ }
2016
+ const parsedLocales = parseLanguageHeader(header);
2017
+ // Handle edge cases:
2018
+ // - No preferred locales provided.
2019
+ // - Only one supported locale.
2020
+ // - Wildcard preference.
2021
+ if (parsedLocales.size === 0 || (parsedLocales.size === 1 && parsedLocales.has('*'))) {
2022
+ return supportedLocales[0];
2023
+ }
2024
+ // Create a map for case-insensitive lookup of supported locales.
2025
+ // Keys are normalized (lowercase) locale values, values are original casing.
2026
+ const normalizedSupportedLocales = new Map();
2027
+ for (const locale of supportedLocales) {
2028
+ normalizedSupportedLocales.set(normalizeLocale(locale), locale);
2029
+ }
2030
+ // Iterate through parsed locales in descending order of quality.
2031
+ let bestMatch;
2032
+ const qualityZeroNormalizedLocales = new Set();
2033
+ for (const [locale, quality] of parsedLocales) {
2034
+ const normalizedLocale = normalizeLocale(locale);
2035
+ if (quality === 0) {
2036
+ qualityZeroNormalizedLocales.add(normalizedLocale);
2037
+ continue; // Skip locales with quality value of 0.
2038
+ }
2039
+ // Exact match found.
2040
+ if (normalizedSupportedLocales.has(normalizedLocale)) {
2041
+ return normalizedSupportedLocales.get(normalizedLocale);
2042
+ }
2043
+ // If an exact match is not found, try prefix matching (e.g., "en" matches "en-US").
2044
+ // Store the first prefix match encountered, as it has the highest quality value.
2045
+ if (bestMatch !== undefined) {
2046
+ continue;
2047
+ }
2048
+ const [languagePrefix] = normalizedLocale.split('-', 1);
2049
+ for (const supportedLocale of normalizedSupportedLocales.keys()) {
2050
+ if (supportedLocale.startsWith(languagePrefix)) {
2051
+ bestMatch = normalizedSupportedLocales.get(supportedLocale);
2052
+ break; // No need to continue searching for this locale.
2053
+ }
2054
+ }
2055
+ }
2056
+ if (bestMatch !== undefined) {
2057
+ return bestMatch;
2058
+ }
2059
+ // Return the first locale that is not quality zero.
2060
+ for (const [normalizedLocale, locale] of normalizedSupportedLocales) {
2061
+ if (!qualityZeroNormalizedLocales.has(normalizedLocale)) {
2062
+ return locale;
2063
+ }
2064
+ }
2065
+ }
2066
+ /**
2067
+ * Normalizes a locale string by converting it to lowercase.
2068
+ *
2069
+ * @param locale - The locale string to normalize.
2070
+ * @returns The normalized locale string in lowercase.
2071
+ *
2072
+ * @example
2073
+ * ```ts
2074
+ * const normalized = normalizeLocale('EN-US');
2075
+ * console.log(normalized); // Output: "en-us"
2076
+ * ```
2077
+ */
2078
+ function normalizeLocale(locale) {
2079
+ return locale.toLowerCase();
2080
+ }
1856
2081
 
1857
2082
  /**
1858
2083
  * Angular server application engine.
@@ -1887,9 +2112,9 @@ class AngularAppEngine {
1887
2112
  */
1888
2113
  manifest = getAngularAppEngineManifest();
1889
2114
  /**
1890
- * The number of entry points available in the server application's manifest.
2115
+ * A map of supported locales from the server application's manifest.
1891
2116
  */
1892
- entryPointsCount = Object.keys(this.manifest.entryPoints).length;
2117
+ supportedLocales = Object.keys(this.manifest.supportedLocales);
1893
2118
  /**
1894
2119
  * A cache that holds entry points, keyed by their potential locale string.
1895
2120
  */
@@ -1907,7 +2132,47 @@ class AngularAppEngine {
1907
2132
  */
1908
2133
  async handle(request, requestContext) {
1909
2134
  const serverApp = await this.getAngularServerAppForRequest(request);
1910
- return serverApp ? serverApp.handle(request, requestContext) : null;
2135
+ if (serverApp) {
2136
+ return serverApp.handle(request, requestContext);
2137
+ }
2138
+ if (this.supportedLocales.length > 1) {
2139
+ // Redirect to the preferred language if i18n is enabled.
2140
+ return this.redirectBasedOnAcceptLanguage(request);
2141
+ }
2142
+ return null;
2143
+ }
2144
+ /**
2145
+ * Handles requests for the base path when i18n is enabled.
2146
+ * Redirects the user to a locale-specific path based on the `Accept-Language` header.
2147
+ *
2148
+ * @param request The incoming request.
2149
+ * @returns A `Response` object with a 302 redirect, or `null` if i18n is not enabled
2150
+ * or the request is not for the base path.
2151
+ */
2152
+ redirectBasedOnAcceptLanguage(request) {
2153
+ const { basePath, supportedLocales } = this.manifest;
2154
+ // If the request is not for the base path, it's not our responsibility to handle it.
2155
+ const url = new URL(request.url);
2156
+ if (url.pathname !== basePath) {
2157
+ return null;
2158
+ }
2159
+ // For requests to the base path (typically '/'), attempt to extract the preferred locale
2160
+ // from the 'Accept-Language' header.
2161
+ const preferredLocale = getPreferredLocale(request.headers.get('Accept-Language') || '*', this.supportedLocales);
2162
+ if (preferredLocale) {
2163
+ const subPath = supportedLocales[preferredLocale];
2164
+ if (subPath !== undefined) {
2165
+ url.pathname = joinUrlParts(url.pathname, subPath);
2166
+ return new Response(null, {
2167
+ status: 302, // Use a 302 redirect as language preference may change.
2168
+ headers: {
2169
+ 'Location': url.toString(),
2170
+ 'Vary': 'Accept-Language',
2171
+ },
2172
+ });
2173
+ }
2174
+ }
2175
+ return null;
1911
2176
  }
1912
2177
  /**
1913
2178
  * Retrieves the Angular server application instance for a given request.
@@ -1969,11 +2234,11 @@ class AngularAppEngine {
1969
2234
  */
1970
2235
  getEntryPointExportsForUrl(url) {
1971
2236
  const { basePath } = this.manifest;
1972
- if (this.entryPointsCount === 1) {
2237
+ if (this.supportedLocales.length === 1) {
1973
2238
  return this.getEntryPointExports('');
1974
2239
  }
1975
2240
  const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);
1976
- return this.getEntryPointExports(potentialLocale);
2241
+ return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
1977
2242
  }
1978
2243
  }
1979
2244