@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 +291 -26
- package/fesm2022/ssr.mjs.map +1 -1
- package/index.d.ts +48 -6
- package/package.json +7 -7
- package/third_party/beasties/index.js +80 -29
- package/third_party/beasties/index.js.map +1 -1
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 `
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
1706
|
+
const { manifest: { locale }, assets, } = this;
|
|
1707
|
+
if (!assets.hasServerAsset(assetPath)) {
|
|
1652
1708
|
return null;
|
|
1653
1709
|
}
|
|
1654
|
-
const { text, hash, size } =
|
|
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
|
-
*
|
|
2115
|
+
* A map of supported locales from the server application's manifest.
|
|
1891
2116
|
*/
|
|
1892
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|