|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright Google LLC All Rights Reserved. |
| 4 | + * |
| 5 | + * Use of this source code is governed by an MIT-style license that can be |
| 6 | + * found in the LICENSE file at https://angular.dev/license |
| 7 | + */ |
| 8 | + |
| 9 | +import { lookup as lookupMimeType } from 'mrmime'; |
| 10 | +import { AngularServerApp } from './app'; |
| 11 | +import { Hooks } from './hooks'; |
| 12 | +import { getPotentialLocaleIdFromUrl } from './i18n'; |
| 13 | +import { getAngularAppEngineManifest } from './manifest'; |
| 14 | + |
| 15 | +/** |
| 16 | + * Angular server application engine. |
| 17 | + * Manages Angular server applications (including localized ones), handles rendering requests, |
| 18 | + * and optionally transforms index HTML before rendering. |
| 19 | + */ |
| 20 | +export class AngularAppEngine { |
| 21 | + /** |
| 22 | + * Hooks for extending or modifying the behavior of the server application. |
| 23 | + * @internal This property is accessed by the Angular CLI when running the dev-server. |
| 24 | + */ |
| 25 | + static hooks = new Hooks(); |
| 26 | + |
| 27 | + /** |
| 28 | + * Hooks for extending or modifying the behavior of the server application. |
| 29 | + * This instance can be used to attach custom functionality to various events in the server application lifecycle. |
| 30 | + * @internal |
| 31 | + */ |
| 32 | + get hooks(): Hooks { |
| 33 | + return AngularAppEngine.hooks; |
| 34 | + } |
| 35 | + |
| 36 | + /** |
| 37 | + * Specifies if the application is operating in development mode. |
| 38 | + * This property controls the activation of features intended for production, such as caching mechanisms. |
| 39 | + * @internal |
| 40 | + */ |
| 41 | + static isDevMode = false; |
| 42 | + |
| 43 | + /** |
| 44 | + * The manifest for the server application. |
| 45 | + */ |
| 46 | + private readonly manifest = getAngularAppEngineManifest(); |
| 47 | + |
| 48 | + /** |
| 49 | + * Map of locale strings to corresponding `AngularServerApp` instances. |
| 50 | + * Each instance represents an Angular server application. |
| 51 | + */ |
| 52 | + private readonly appsCache = new Map<string, AngularServerApp>(); |
| 53 | + |
| 54 | + /** |
| 55 | + * Renders an HTTP request using the appropriate Angular server application and returns a response. |
| 56 | + * |
| 57 | + * This method determines the entry point for the Angular server application based on the request URL, |
| 58 | + * and caches the server application instances for reuse. If the application is in development mode, |
| 59 | + * the cache is bypassed and a new instance is created for each request. |
| 60 | + * |
| 61 | + * If the request URL appears to be for a file (excluding `/index.html`), the method returns `null`. |
| 62 | + * A request to `https://www.example.com/page/index.html` will render the Angular route |
| 63 | + * corresponding to `https://www.example.com/page`. |
| 64 | + * |
| 65 | + * @param request - The incoming HTTP request object to be rendered. |
| 66 | + * @param requestContext - Optional additional context for the request, such as metadata. |
| 67 | + * @returns A promise that resolves to a Response object, or `null` if the request URL represents a file (e.g., `./logo.png`) |
| 68 | + * rather than an application route. |
| 69 | + */ |
| 70 | + async render(request: Request, requestContext?: unknown): Promise<Response | null> { |
| 71 | + // Skip if the request looks like a file but not `/index.html`. |
| 72 | + const url = new URL(request.url); |
| 73 | + const { pathname } = url; |
| 74 | + if (isFileLike(pathname) && !pathname.endsWith('/index.html')) { |
| 75 | + return null; |
| 76 | + } |
| 77 | + |
| 78 | + const entryPoint = this.getEntryPointFromUrl(url); |
| 79 | + if (!entryPoint) { |
| 80 | + return null; |
| 81 | + } |
| 82 | + |
| 83 | + const [locale, loadModule] = entryPoint; |
| 84 | + let serverApp = this.appsCache.get(locale); |
| 85 | + if (!serverApp) { |
| 86 | + const { AngularServerApp } = await loadModule(); |
| 87 | + serverApp = new AngularServerApp({ |
| 88 | + isDevMode: AngularAppEngine.isDevMode, |
| 89 | + hooks: this.hooks, |
| 90 | + }); |
| 91 | + |
| 92 | + if (!AngularAppEngine.isDevMode) { |
| 93 | + this.appsCache.set(locale, serverApp); |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + return serverApp.render(request, requestContext); |
| 98 | + } |
| 99 | + |
| 100 | + /** |
| 101 | + * Retrieves the entry point path and locale for the Angular server application based on the provided URL. |
| 102 | + * |
| 103 | + * This method determines the appropriate entry point and locale for rendering the application by examining the URL. |
| 104 | + * If there is only one entry point available, it is returned regardless of the URL. |
| 105 | + * Otherwise, the method extracts a potential locale identifier from the URL and looks up the corresponding entry point. |
| 106 | + * |
| 107 | + * @param url - The URL used to derive the locale and determine the entry point. |
| 108 | + * @returns An array containing: |
| 109 | + * - The first element is the locale extracted from the URL. |
| 110 | + * - The second element is a function that returns a promise resolving to an object with the `AngularServerApp` type. |
| 111 | + * |
| 112 | + * Returns `null` if no matching entry point is found for the extracted locale. |
| 113 | + */ |
| 114 | + private getEntryPointFromUrl(url: URL): |
| 115 | + | [ |
| 116 | + locale: string, |
| 117 | + loadModule: () => Promise<{ |
| 118 | + AngularServerApp: typeof AngularServerApp; |
| 119 | + }>, |
| 120 | + ] |
| 121 | + | null { |
| 122 | + // Find bundle for locale |
| 123 | + const { entryPoints, basePath } = this.manifest; |
| 124 | + if (entryPoints.size === 1) { |
| 125 | + return entryPoints.entries().next().value; |
| 126 | + } |
| 127 | + |
| 128 | + const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath); |
| 129 | + const entryPoint = entryPoints.get(potentialLocale); |
| 130 | + |
| 131 | + return entryPoint ? [potentialLocale, entryPoint] : null; |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +/** |
| 136 | + * Determines if the given pathname corresponds to a file-like resource. |
| 137 | + * |
| 138 | + * @param pathname - The pathname to check. |
| 139 | + * @returns True if the pathname appears to be a file, false otherwise. |
| 140 | + */ |
| 141 | +function isFileLike(pathname: string): boolean { |
| 142 | + const dotIndex = pathname.lastIndexOf('.'); |
| 143 | + if (dotIndex === -1) { |
| 144 | + return false; |
| 145 | + } |
| 146 | + |
| 147 | + const extension = pathname.slice(dotIndex); |
| 148 | + |
| 149 | + return extension === '.ico' || !!lookupMimeType(extension); |
| 150 | +} |
0 commit comments