UNPKG

@angular/ssr

Version:

Angular server side rendering utilities

1,565 lines (1,543 loc) 51.9 kB
import { normalizeTrustProxyHeaders, sanitizeRequestHeaders, validateRequest } from './_validation-chunk.mjs'; import { APP_BASE_HREF, PlatformLocation } from '@angular/common'; import { ɵConsole as _Console, ApplicationRef, REQUEST, makeEnvironmentProviders, provideEnvironmentInitializer, inject, InjectionToken, ɵENABLE_ROOT_COMPONENT_BOOTSTRAP as _ENABLE_ROOT_COMPONENT_BOOTSTRAP, Compiler, createEnvironmentInjector, EnvironmentInjector, runInInjectionContext, ɵresetCompiledComponents as _resetCompiledComponents, REQUEST_CONTEXT, RESPONSE_INIT, LOCALE_ID } from '@angular/core'; import { platformServer, INITIAL_CONFIG, ɵSERVER_CONTEXT as _SERVER_CONTEXT, ɵrenderInternal as _renderInternal, provideServerRendering as provideServerRendering$1 } from '@angular/platform-server'; import { ActivatedRoute, Router, ROUTES, ɵloadChildren as _loadChildren } from '@angular/router'; import Beasties from '../third_party/beasties/index.js'; class ServerAssets { manifest; constructor(manifest) { this.manifest = manifest; } getServerAsset(path) { const asset = this.manifest.assets[path]; if (!asset) { throw new Error(`Server asset '${path}' does not exist.`); } return asset; } hasServerAsset(path) { return !!this.manifest.assets[path]; } getIndexServerHtml() { return this.getServerAsset('index.server.html'); } } const IGNORED_LOGS = new Set(['Angular is running in development mode.']); class Console extends _Console { log(message) { if (!IGNORED_LOGS.has(message)) { super.log(message); } } } let angularAppManifest; function setAngularAppManifest(manifest) { angularAppManifest = manifest; } function getAngularAppManifest() { if (!angularAppManifest) { throw new Error('Angular app manifest is not set. ' + `Please ensure you are using the '@angular/build:application' builder to build your server application.`); } return angularAppManifest; } let angularAppEngineManifest; function setAngularAppEngineManifest(manifest) { angularAppEngineManifest = manifest; } function getAngularAppEngineManifest() { if (!angularAppEngineManifest) { throw new Error('Angular app engine manifest is not set. ' + `Please ensure you are using the '@angular/build:application' builder to build your server application.`); } return angularAppEngineManifest; } function stripTrailingSlash(url) { return url.length > 1 && url.at(-1) === '/' ? url.slice(0, -1) : url; } function stripLeadingSlash(url) { return url.length > 1 && url[0] === '/' ? url.slice(1) : url; } function addLeadingSlash(url) { return url[0] === '/' ? url : `/${url}`; } function addTrailingSlash(url) { return url.at(-1) === '/' ? url : `${url}/`; } function joinUrlParts(...parts) { const normalizedParts = []; for (const part of parts) { if (part === '') { continue; } let start = 0; let end = part.length; while (start < end && part[start] === '/') { start++; } while (end > start && part[end - 1] === '/') { end--; } if (start < end) { normalizedParts.push(part.slice(start, end)); } } return addLeadingSlash(normalizedParts.join('/')); } function stripIndexHtmlFromURL(url) { if (url.pathname.endsWith('/index.html')) { const modifiedURL = new URL(url); modifiedURL.pathname = modifiedURL.pathname.slice(0, -11); return modifiedURL; } return url; } function buildPathWithParams(toPath, fromPath) { if (toPath[0] !== '/') { throw new Error(`Invalid toPath: The string must start with a '/'. Received: '${toPath}'`); } if (fromPath[0] !== '/') { throw new Error(`Invalid fromPath: The string must start with a '/'. Received: '${fromPath}'`); } if (!toPath.includes('/*')) { return toPath; } const fromPathParts = fromPath.split('/'); const toPathParts = toPath.split('/'); const resolvedParts = toPathParts.map((part, index) => toPathParts[index] === '*' ? fromPathParts[index] : part); return joinUrlParts(...resolvedParts); } const MATRIX_PARAMS_REGEX = /;[^/]+/g; function stripMatrixParams(pathname) { return pathname.includes(';') ? pathname.replace(MATRIX_PARAMS_REGEX, '') : pathname; } async function renderAngular(html, bootstrap, url, platformProviders, serverContext) { const urlToRender = stripIndexHtmlFromURL(url); const platformRef = platformServer([{ provide: INITIAL_CONFIG, useValue: { url: urlToRender.href, document: html } }, { provide: _SERVER_CONTEXT, useValue: serverContext }, { provide: _Console, useFactory: () => new Console() }, ...platformProviders]); let redirectTo; let hasNavigationError = true; try { let applicationRef; if (isNgModule(bootstrap)) { const moduleRef = await platformRef.bootstrapModule(bootstrap); applicationRef = moduleRef.injector.get(ApplicationRef); } else { applicationRef = await bootstrap({ platformRef }); } await applicationRef.whenStable(); if (applicationRef.destroyed) { return { hasNavigationError: true }; } const envInjector = applicationRef.injector; const routerIsProvided = !!envInjector.get(ActivatedRoute, null); const router = envInjector.get(Router); const lastSuccessfulNavigation = router.lastSuccessfulNavigation(); if (!routerIsProvided) { hasNavigationError = false; } else if (lastSuccessfulNavigation?.finalUrl) { hasNavigationError = false; const requestPrefix = envInjector.get(APP_BASE_HREF, null, { optional: true }) ?? envInjector.get(REQUEST, null, { optional: true })?.headers.get('X-Forwarded-Prefix'); const { pathname, search, hash } = envInjector.get(PlatformLocation); const finalUrl = constructSerializedUrl(router, { pathname, search, hash }, requestPrefix); const urlToRenderString = constructSerializedUrl(router, urlToRender, requestPrefix); if (urlToRenderString !== finalUrl) { redirectTo = [pathname, search, hash].join(''); } } return { hasNavigationError, redirectTo, content: () => new Promise((resolve, reject) => { setTimeout(() => { _renderInternal(platformRef, applicationRef).then(resolve).catch(reject).finally(() => void asyncDestroyPlatform(platformRef)); }, 0); }) }; } catch (error) { await asyncDestroyPlatform(platformRef); throw error; } finally { if (hasNavigationError || redirectTo) { void asyncDestroyPlatform(platformRef); } } } function isNgModule(value) { return 'ɵmod' in value; } function asyncDestroyPlatform(platformRef) { return new Promise(resolve => { setTimeout(() => { if (!platformRef.destroyed) { platformRef.destroy(); } resolve(); }, 0); }); } function constructSerializedUrl(router, url, prefix) { const { pathname, hash, search } = url; const urlParts = []; if (prefix && !addTrailingSlash(pathname).startsWith(addTrailingSlash(prefix))) { urlParts.push(joinUrlParts(prefix, pathname)); } else { urlParts.push(stripTrailingSlash(pathname)); } urlParts.push(search, hash); const urlTree = router.parseUrl(urlParts.join('')); return router.serializeUrl(urlTree); } function promiseWithAbort(promise, signal, errorMessagePrefix) { return new Promise((resolve, reject) => { const abortHandler = () => { reject(new DOMException(`${errorMessagePrefix} was aborted.\n${signal.reason}`, 'AbortError')); }; if (signal.aborted) { abortHandler(); return; } signal.addEventListener('abort', abortHandler, { once: true }); promise.then(resolve).catch(reject).finally(() => { signal.removeEventListener('abort', abortHandler); }); }); } const VALID_REDIRECT_RESPONSE_CODES = new Set([301, 302, 303, 307, 308]); function isValidRedirectResponseCode(code) { return VALID_REDIRECT_RESPONSE_CODES.has(code); } function createRedirectResponse(location, status = 302, headers) { if (ngDevMode && !isValidRedirectResponseCode(status)) { throw new Error(`Invalid redirect status code: ${status}. ` + `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`); } const resHeaders = headers instanceof Headers ? headers : new Headers(headers); if (ngDevMode && resHeaders.has('location')) { console.warn(`Location header "${resHeaders.get('location')}" will be ignored and set to "${location}".`); } const varyArray = resHeaders.get('Vary')?.split(',') ?? []; const varySet = new Set(['X-Forwarded-Prefix']); for (const vary of varyArray) { const value = vary.trim(); if (value) { varySet.add(value); } } resHeaders.set('Vary', [...varySet].join(', ')); resHeaders.set('Location', location); return new Response(null, { status, headers: resHeaders }); } const APP_SHELL_ROUTE = 'ng-app-shell'; var ServerRenderingFeatureKind; (function (ServerRenderingFeatureKind) { ServerRenderingFeatureKind[ServerRenderingFeatureKind["AppShell"] = 0] = "AppShell"; ServerRenderingFeatureKind[ServerRenderingFeatureKind["ServerRoutes"] = 1] = "ServerRoutes"; })(ServerRenderingFeatureKind || (ServerRenderingFeatureKind = {})); var RenderMode; (function (RenderMode) { RenderMode[RenderMode["Server"] = 0] = "Server"; RenderMode[RenderMode["Client"] = 1] = "Client"; RenderMode[RenderMode["Prerender"] = 2] = "Prerender"; })(RenderMode || (RenderMode = {})); var PrerenderFallback; (function (PrerenderFallback) { PrerenderFallback[PrerenderFallback["Server"] = 0] = "Server"; PrerenderFallback[PrerenderFallback["Client"] = 1] = "Client"; PrerenderFallback[PrerenderFallback["None"] = 2] = "None"; })(PrerenderFallback || (PrerenderFallback = {})); const SERVER_ROUTES_CONFIG = new InjectionToken('SERVER_ROUTES_CONFIG'); function withRoutes(routes) { const config = { routes }; return { ɵkind: ServerRenderingFeatureKind.ServerRoutes, ɵproviders: [{ provide: SERVER_ROUTES_CONFIG, useValue: config }] }; } function withAppShell(component) { const routeConfig = { path: APP_SHELL_ROUTE }; if ('ɵcmp' in component) { routeConfig.component = component; } else { routeConfig.loadComponent = component; } return { ɵkind: ServerRenderingFeatureKind.AppShell, ɵproviders: [{ provide: ROUTES, useValue: routeConfig, multi: true }, provideEnvironmentInitializer(() => { const config = inject(SERVER_ROUTES_CONFIG); config.appShellRoute = APP_SHELL_ROUTE; })] }; } function provideServerRendering(...args) { let options; let features; if (hasOptions(args)) { const [first, ...rest] = args; options = first; features = rest; } else { features = args; } const providers = [provideServerRendering$1(options)]; let hasAppShell = false; let hasServerRoutes = false; for (const { ɵkind, ɵproviders } of features) { hasAppShell ||= ɵkind === ServerRenderingFeatureKind.AppShell; hasServerRoutes ||= ɵkind === ServerRenderingFeatureKind.ServerRoutes; providers.push(...ɵproviders); } if (!hasServerRoutes && hasAppShell) { throw new Error(`Configuration error: found 'withAppShell()' without 'withRoutes()' in the same call to 'provideServerRendering()'.` + `The 'withAppShell()' function requires 'withRoutes()' to be used.`); } return makeEnvironmentProviders(providers); } function hasOptions(args) { const value = args[0]; return !!value && typeof value === 'object' && !('ɵkind' in value); } class RouteTree { root = this.createEmptyRouteTreeNode(); insert(route, metadata) { let node = this.root; const segments = this.getPathSegments(route); const normalizedSegments = []; for (const segment of segments) { const normalizedSegment = segment[0] === ':' ? '*' : segment; let childNode = node.children.get(normalizedSegment); if (!childNode) { childNode = this.createEmptyRouteTreeNode(); node.children.set(normalizedSegment, childNode); } node = childNode; normalizedSegments.push(normalizedSegment); } node.metadata = { ...metadata, route: addLeadingSlash(normalizedSegments.join('/')) }; } match(route) { const segments = this.getPathSegments(route); return this.traverseBySegments(segments)?.metadata; } toObject() { return Array.from(this.traverse()); } static fromObject(value) { const tree = new RouteTree(); for (const { route, ...metadata } of value) { tree.insert(route, metadata); } return tree; } *traverse(node = this.root) { if (node.metadata) { yield node.metadata; } for (const childNode of node.children.values()) { yield* this.traverse(childNode); } } getPathSegments(route) { return route.split('/').filter(Boolean).map(decodeURIComponent); } traverseBySegments(segments, node = this.root, currentIndex = 0) { if (currentIndex >= segments.length) { return node.metadata ? node : node.children.get('**'); } if (!node.children.size) { return undefined; } const segment = segments[currentIndex]; const exactMatch = node.children.get(segment); if (exactMatch) { const match = this.traverseBySegments(segments, exactMatch, currentIndex + 1); if (match) { return match; } } const wildcardMatch = node.children.get('*'); if (wildcardMatch) { const match = this.traverseBySegments(segments, wildcardMatch, currentIndex + 1); if (match) { return match; } } return node.children.get('**'); } createEmptyRouteTreeNode() { return { children: new Map() }; } } const IS_DISCOVERING_ROUTES = new InjectionToken(typeof ngDevMode === 'undefined' || ngDevMode ? 'IS_DISCOVERING_ROUTES' : '', { providedIn: 'platform', factory: () => false }); const MODULE_PRELOAD_MAX = 10; const CATCH_ALL_REGEXP = /\/(\*\*)$/; const URL_PARAMETER_REGEXP = /(?<!\\):([^/]+)/; const URL_PARAMETER_GLOBAL_REGEXP = new RegExp(URL_PARAMETER_REGEXP, 'g'); async function* handleRoute(options) { try { const { metadata, currentRoutePath, route, compiler, parentInjector, serverConfigRouteTree, entryPointToBrowserMapping, invokeGetPrerenderParams, includePrerenderFallbackRoutes } = options; const { redirectTo, loadChildren, loadComponent, children, ɵentryName } = route; if (ɵentryName && loadComponent) { appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata); } if (metadata.renderMode === RenderMode.Prerender) { yield* handleSSGRoute(serverConfigRouteTree, typeof redirectTo === 'string' ? redirectTo : undefined, metadata, parentInjector, invokeGetPrerenderParams, includePrerenderFallbackRoutes); } else if (redirectTo !== undefined) { if (metadata.status && !isValidRedirectResponseCode(metadata.status)) { yield { error: `The '${metadata.status}' status code is not a valid redirect response code. ` + `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.` }; } else if (typeof redirectTo === 'string') { yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) }; } else { yield metadata; } } else { yield metadata; } if (children?.length) { yield* traverseRoutesConfig({ ...options, routes: children, parentRoute: currentRoutePath, parentPreloads: metadata.preload }); } if (loadChildren) { if (ɵentryName) { appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata); } const routeInjector = route.providers ? createEnvironmentInjector(route.providers, parentInjector.get(EnvironmentInjector), `Route: ${route.path}`) : parentInjector; const loadedChildRoutes = await _loadChildren(route, compiler, routeInjector); if (loadedChildRoutes) { const { routes: childRoutes, injector = routeInjector } = loadedChildRoutes; yield* traverseRoutesConfig({ ...options, routes: childRoutes, parentInjector: injector, parentRoute: currentRoutePath, parentPreloads: metadata.preload }); } } } catch (error) { yield { error: `Error in handleRoute for '${options.currentRoutePath}': ${error.message}` }; } } async function* traverseRoutesConfig(options) { const { routes: routeConfigs, parentPreloads, parentRoute, serverConfigRouteTree } = options; for (const route of routeConfigs) { const { matcher, path = matcher ? '**' : '' } = route; const currentRoutePath = joinUrlParts(parentRoute, path); if (matcher && serverConfigRouteTree) { const matches = []; for (const matchedMetaData of serverConfigRouteTree.traverse()) { if (matchedMetaData.route.startsWith(currentRoutePath)) { matches.push(matchedMetaData); } } if (!matches.length) { const matchedMetaData = serverConfigRouteTree.match(currentRoutePath); if (matchedMetaData) { matches.push(matchedMetaData); } } for (const matchedMetaData of matches) { matchedMetaData.presentInClientRouter = true; if (matchedMetaData.renderMode === RenderMode.Prerender) { yield { error: `The route '${stripLeadingSlash(currentRoutePath)}' is set for prerendering but has a defined matcher. ` + `Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.` }; continue; } yield* handleRoute({ ...options, currentRoutePath, route, metadata: { ...matchedMetaData, preload: parentPreloads, route: matchedMetaData.route, presentInClientRouter: undefined } }); } if (!matches.length) { yield { error: `The route '${stripLeadingSlash(currentRoutePath)}' has a defined matcher but does not ` + 'match any route in the server routing configuration. Please ensure this route is added to the server routing configuration.' }; } continue; } let matchedMetaData; if (serverConfigRouteTree) { matchedMetaData = serverConfigRouteTree.match(currentRoutePath); if (!matchedMetaData) { yield { error: `The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` + 'Please ensure this route is added to the server routing configuration.' }; continue; } matchedMetaData.presentInClientRouter = true; } yield* handleRoute({ ...options, metadata: { renderMode: RenderMode.Prerender, ...matchedMetaData, preload: parentPreloads, route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath, presentInClientRouter: undefined }, currentRoutePath, route }); } } function appendPreloadToMetadata(entryName, entryPointToBrowserMapping, metadata) { const existingPreloads = metadata.preload ?? []; if (!entryPointToBrowserMapping || existingPreloads.length >= MODULE_PRELOAD_MAX) { return; } const preload = entryPointToBrowserMapping[entryName]; if (!preload?.length) { return; } const combinedPreloads = new Set(existingPreloads); for (const href of preload) { combinedPreloads.add(href); if (combinedPreloads.size === MODULE_PRELOAD_MAX) { break; } } metadata.preload = Array.from(combinedPreloads); } async function* handleSSGRoute(serverConfigRouteTree, redirectTo, metadata, parentInjector, invokeGetPrerenderParams, includePrerenderFallbackRoutes) { if (metadata.renderMode !== RenderMode.Prerender) { throw new Error(`'handleSSGRoute' was called for a route which rendering mode is not prerender.`); } const { route: currentRoutePath, fallback, ...meta } = metadata; const getPrerenderParams = 'getPrerenderParams' in meta ? meta.getPrerenderParams : undefined; if ('getPrerenderParams' in meta) { delete meta['getPrerenderParams']; } if (redirectTo !== undefined) { meta.redirectTo = resolveRedirectTo(currentRoutePath, redirectTo); } const isCatchAllRoute = CATCH_ALL_REGEXP.test(currentRoutePath); if (isCatchAllRoute && !getPrerenderParams || !isCatchAllRoute && !URL_PARAMETER_REGEXP.test(currentRoutePath)) { yield { ...meta, route: currentRoutePath }; return; } if (invokeGetPrerenderParams) { if (!getPrerenderParams) { yield { error: `The '${stripLeadingSlash(currentRoutePath)}' route uses prerendering and includes parameters, but 'getPrerenderParams' ` + `is missing. Please define 'getPrerenderParams' function for this route in your server routing configuration ` + `or specify a different 'renderMode'.` }; return; } if (serverConfigRouteTree) { const catchAllRoutePath = isCatchAllRoute ? currentRoutePath : joinUrlParts(currentRoutePath, '**'); const match = serverConfigRouteTree.match(catchAllRoutePath); if (match && match.renderMode === RenderMode.Prerender && !('getPrerenderParams' in match)) { serverConfigRouteTree.insert(catchAllRoutePath, { ...match, presentInClientRouter: true, getPrerenderParams }); } } const parameters = await runInInjectionContext(parentInjector, () => getPrerenderParams()); try { for (const params of parameters) { const replacer = handlePrerenderParamsReplacement(params, currentRoutePath); const routeWithResolvedParams = currentRoutePath.replace(URL_PARAMETER_GLOBAL_REGEXP, replacer).replace(CATCH_ALL_REGEXP, replacer); yield { ...meta, route: routeWithResolvedParams, redirectTo: redirectTo === undefined ? undefined : resolveRedirectTo(routeWithResolvedParams, redirectTo) }; } } catch (error) { yield { error: `${error.message}` }; return; } } if (includePrerenderFallbackRoutes && (fallback !== PrerenderFallback.None || !invokeGetPrerenderParams)) { yield { ...meta, route: currentRoutePath, renderMode: fallback === PrerenderFallback.Client ? RenderMode.Client : RenderMode.Server }; } } function handlePrerenderParamsReplacement(params, currentRoutePath) { return match => { const parameterName = match.slice(1); const value = params[parameterName]; if (typeof value !== 'string') { throw new Error(`The 'getPrerenderParams' function defined for the '${stripLeadingSlash(currentRoutePath)}' route ` + `returned a non-string value for parameter '${parameterName}'. ` + `Please make sure the 'getPrerenderParams' function returns values for all parameters ` + 'specified in this route.'); } return parameterName === '**' ? `/${value}` : value; }; } function resolveRedirectTo(routePath, redirectTo) { if (redirectTo[0] === '/') { return redirectTo; } const segments = routePath.replace(URL_PARAMETER_GLOBAL_REGEXP, '*').split('/'); segments.pop(); return joinUrlParts(...segments, redirectTo); } function buildServerConfigRouteTree({ routes, appShellRoute }) { const serverRoutes = [...routes]; if (appShellRoute !== undefined) { serverRoutes.unshift({ path: appShellRoute, renderMode: RenderMode.Prerender }); } const serverConfigRouteTree = new RouteTree(); const errors = []; for (const { path, ...metadata } of serverRoutes) { if (path[0] === '/') { errors.push(`Invalid '${path}' route configuration: the path cannot start with a slash.`); continue; } if ('getPrerenderParams' in metadata && (path.includes('/*/') || path.endsWith('/*'))) { errors.push(`Invalid '${path}' route configuration: 'getPrerenderParams' cannot be used with a '*' route.`); continue; } serverConfigRouteTree.insert(path, metadata); } return { serverConfigRouteTree, errors }; } async function getRoutesFromAngularRouterConfig(bootstrap, document, url, invokeGetPrerenderParams = false, includePrerenderFallbackRoutes = true, entryPointToBrowserMapping = undefined) { const { protocol, host } = url; const platformRef = platformServer([{ provide: INITIAL_CONFIG, useValue: { document, url: `${protocol}//${host}/` } }, { provide: _Console, useFactory: () => new Console() }, { provide: _ENABLE_ROOT_COMPONENT_BOOTSTRAP, useValue: false }, { provide: IS_DISCOVERING_ROUTES, useValue: true }]); try { let applicationRef; if (isNgModule(bootstrap)) { const moduleRef = await platformRef.bootstrapModule(bootstrap); applicationRef = moduleRef.injector.get(ApplicationRef); } else { applicationRef = await bootstrap({ platformRef }); } const injector = applicationRef.injector; const router = injector.get(Router); router.navigationTransitions.afterPreactivation()?.next?.(); await applicationRef.whenStable(); const errors = []; const rawBaseHref = injector.get(APP_BASE_HREF, null, { optional: true }) ?? injector.get(PlatformLocation).getBaseHrefFromDOM(); const { pathname: baseHref } = new URL(rawBaseHref, 'http://localhost'); const compiler = injector.get(Compiler); const serverRoutesConfig = injector.get(SERVER_ROUTES_CONFIG, null, { optional: true }); let serverConfigRouteTree; if (serverRoutesConfig) { const result = buildServerConfigRouteTree(serverRoutesConfig); serverConfigRouteTree = result.serverConfigRouteTree; errors.push(...result.errors); } if (errors.length) { return { baseHref, routes: [], errors }; } const routesResults = []; if (router.config.length) { const traverseRoutes = traverseRoutesConfig({ routes: router.config, compiler, parentInjector: injector, parentRoute: '', serverConfigRouteTree, invokeGetPrerenderParams, includePrerenderFallbackRoutes, entryPointToBrowserMapping }); const seenRoutes = new Set(); for await (const routeMetadata of traverseRoutes) { if ('error' in routeMetadata) { errors.push(routeMetadata.error); continue; } const routePath = routeMetadata.route; if (!seenRoutes.has(routePath)) { routesResults.push(routeMetadata); seenRoutes.add(routePath); } } await new Promise(resolve => setTimeout(resolve, 0)); if (serverConfigRouteTree) { for (const { route, presentInClientRouter } of serverConfigRouteTree.traverse()) { if (presentInClientRouter || route.endsWith('/**')) { continue; } errors.push(`The '${stripLeadingSlash(route)}' server route does not match any routes defined in the Angular ` + `routing configuration (typically provided as a part of the 'provideRouter' call). ` + 'Please make sure that the mentioned server route is present in the Angular routing configuration.'); } } } else { const rootRouteMetadata = serverConfigRouteTree?.match('') ?? { route: '', renderMode: RenderMode.Prerender }; routesResults.push({ ...rootRouteMetadata, route: '' }); } return { baseHref, routes: routesResults, errors, appShellRoute: serverRoutesConfig?.appShellRoute }; } finally { platformRef.destroy(); } } function extractRoutesAndCreateRouteTree(options) { const { url, manifest = getAngularAppManifest(), invokeGetPrerenderParams = false, includePrerenderFallbackRoutes = true, signal } = options; async function extract() { const routeTree = new RouteTree(); const document = await new ServerAssets(manifest).getIndexServerHtml().text(); const bootstrap = await manifest.bootstrap(); const { baseHref, appShellRoute, routes, errors } = await getRoutesFromAngularRouterConfig(bootstrap, document, url, invokeGetPrerenderParams, includePrerenderFallbackRoutes, manifest.entryPointToBrowserMapping); for (const { route, ...metadata } of routes) { if (metadata.redirectTo !== undefined) { metadata.redirectTo = joinUrlParts(baseHref, metadata.redirectTo); } for (const [key, value] of Object.entries(metadata)) { if (value === undefined) { delete metadata[key]; } } const fullRoute = joinUrlParts(baseHref, route); routeTree.insert(fullRoute, metadata); } return { appShellRoute, routeTree, errors }; } return signal ? promiseWithAbort(extract(), signal, 'Routes extraction') : extract(); } class Hooks { store = new Map(); async run(name, context) { const hooks = this.store.get(name); switch (name) { case 'html:transform:pre': { if (!hooks) { return context.html; } const ctx = { ...context }; for (const hook of hooks) { ctx.html = await hook(ctx); } return ctx.html; } default: throw new Error(`Running hook "${name}" is not supported.`); } } on(name, handler) { const hooks = this.store.get(name); if (hooks) { hooks.push(handler); } else { this.store.set(name, [handler]); } } has(name) { return !!this.store.get(name)?.length; } } class ServerRouter { routeTree; constructor(routeTree) { this.routeTree = routeTree; } static #extractionPromise; static from(manifest, url) { if (manifest.routes) { const routeTree = RouteTree.fromObject(manifest.routes); return Promise.resolve(new ServerRouter(routeTree)); } ServerRouter.#extractionPromise ??= extractRoutesAndCreateRouteTree({ url, manifest }).then(({ routeTree, errors }) => { if (errors.length > 0) { throw new Error('Error(s) occurred while extracting routes:\n' + errors.map(error => `- ${error}`).join('\n')); } return new ServerRouter(routeTree); }).finally(() => { ServerRouter.#extractionPromise = undefined; }); return ServerRouter.#extractionPromise; } match(url) { let { pathname } = stripIndexHtmlFromURL(url); pathname = stripMatrixParams(pathname); return this.routeTree.match(pathname); } } async function sha256(data) { const encodedData = new TextEncoder().encode(data); const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData); const hashParts = []; for (const h of new Uint8Array(hashBuffer)) { hashParts.push(h.toString(16).padStart(2, '0')); } return hashParts.join(''); } const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; const CSP_MEDIA_ATTR = 'ngCspMedia'; const LINK_LOAD_SCRIPT_CONTENT = /* @__PURE__ */(() => `(() => { const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}'; const documentElement = document.documentElement; // Listener for load events on link tags. const listener = (e) => { const target = e.target; if ( !target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR) ) { return; } target.media = target.getAttribute(CSP_MEDIA_ATTR); target.removeAttribute(CSP_MEDIA_ATTR); if (!document.head.querySelector(\`link[\${CSP_MEDIA_ATTR}]\`)) { documentElement.removeEventListener('load', listener); } }; documentElement.addEventListener('load', listener, true); })();`)(); class BeastiesBase extends Beasties {} class InlineCriticalCssProcessor extends BeastiesBase { readFile; outputPath; addedCspScriptsDocuments = new WeakSet(); documentNonces = new WeakMap(); constructor(readFile, outputPath) { super({ logger: { warn: s => console.warn(s), error: s => console.error(s), info: () => {} }, logLevel: 'warn', path: outputPath, publicPath: undefined, compress: false, pruneSource: false, reduceInlineStyles: false, mergeStylesheets: false, preload: 'media', noscriptFallback: true, inlineFonts: true }); this.readFile = readFile; this.outputPath = outputPath; } async embedLinkedStylesheet(link, document) { if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); if (media) { link.removeAttribute('onload'); link.setAttribute('media', media[1]); link?.next?.remove(); } } const returnValue = await super.embedLinkedStylesheet(link, document); const cspNonce = this.findCspNonce(document); if (cspNonce) { const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); if (beastiesMedia) { link.removeAttribute('onload'); link.setAttribute(CSP_MEDIA_ATTR, beastiesMedia[1]); this.conditionallyInsertCspLoadingScript(document, cspNonce, link); } document.head.children.forEach(child => { if (child.tagName === 'style' && !child.hasAttribute('nonce')) { child.setAttribute('nonce', cspNonce); } }); } return returnValue; } findCspNonce(document) { if (this.documentNonces.has(document)) { return this.documentNonces.get(document); } const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); const cspNonce = nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null; this.documentNonces.set(document, cspNonce); return cspNonce; } conditionallyInsertCspLoadingScript(document, nonce, link) { if (this.addedCspScriptsDocuments.has(document)) { return; } if (document.head.textContent.includes(LINK_LOAD_SCRIPT_CONTENT)) { this.addedCspScriptsDocuments.add(document); return; } const script = document.createElement('script'); script.setAttribute('nonce', nonce); script.textContent = LINK_LOAD_SCRIPT_CONTENT; document.head.insertBefore(script, link); this.addedCspScriptsDocuments.add(document); } } class LRUCache { capacity; cache = new Map(); head; tail; constructor(capacity) { this.capacity = capacity; } get(key) { const node = this.cache.get(key); if (node) { this.moveToHead(node); return node.value; } return undefined; } put(key, value) { const cachedNode = this.cache.get(key); if (cachedNode) { cachedNode.value = value; this.moveToHead(cachedNode); return; } const newNode = { key, value, prev: undefined, next: undefined }; this.cache.set(key, newNode); this.addToHead(newNode); if (this.cache.size > this.capacity) { const tail = this.removeTail(); if (tail) { this.cache.delete(tail.key); } } } addToHead(node) { node.next = this.head; node.prev = undefined; if (this.head) { this.head.prev = node; } this.head = node; if (!this.tail) { this.tail = node; } } removeNode(node) { if (node.prev) { node.prev.next = node.next; } else { this.head = node.next; } if (node.next) { node.next.prev = node.prev; } else { this.tail = node.prev; } } moveToHead(node) { this.removeNode(node); this.addToHead(node); } removeTail() { const node = this.tail; if (node) { this.removeNode(node); } return node; } } const WELL_KNOWN_NON_ANGULAR_URLS = new Set(['/favicon.ico', '/.well-known/appspecific/com.chrome.devtools.json']); const MAX_INLINE_CSS_CACHE_ENTRIES = 50; const SERVER_CONTEXT_VALUE = { [RenderMode.Prerender]: 'ssg', [RenderMode.Server]: 'ssr', [RenderMode.Client]: '' }; class AngularServerApp { options; allowStaticRouteRender; hooks; constructor(options = {}) { this.options = options; this.allowStaticRouteRender = this.options.allowStaticRouteRender ?? false; this.hooks = options.hooks ?? new Hooks(); if (this.manifest.inlineCriticalCss) { this.inlineCriticalCssProcessor = new InlineCriticalCssProcessor(path => { const fileName = path.split('/').pop() ?? path; return this.assets.getServerAsset(fileName).text(); }); } } manifest = getAngularAppManifest(); assets = new ServerAssets(this.manifest); router; inlineCriticalCssProcessor; boostrap; textDecoder = new TextEncoder(); criticalCssLRUCache = new LRUCache(MAX_INLINE_CSS_CACHE_ENTRIES); async handle(request, requestContext) { const url = new URL(request.url); if (WELL_KNOWN_NON_ANGULAR_URLS.has(url.pathname)) { return null; } this.router ??= await ServerRouter.from(this.manifest, url); const matchedRoute = this.router.match(url); if (!matchedRoute) { return null; } const { redirectTo, status, renderMode, headers } = matchedRoute; if (redirectTo !== undefined) { return createRedirectResponse(joinUrlParts(request.headers.get('X-Forwarded-Prefix') ?? '', buildPathWithParams(redirectTo, url.pathname)), status, headers); } if (renderMode === RenderMode.Prerender) { const response = await this.handleServe(request, matchedRoute); if (response) { return response; } } return promiseWithAbort(this.handleRendering(request, matchedRoute, requestContext), request.signal, `Request for: ${request.url}`); } async handleServe(request, matchedRoute) { const { headers, renderMode } = matchedRoute; if (renderMode !== RenderMode.Prerender) { return null; } const { method } = request; if (method !== 'GET' && method !== 'HEAD') { return null; } const assetPath = this.buildServerAssetPathFromRequest(request); const { manifest: { locale }, assets } = this; if (!assets.hasServerAsset(assetPath)) { return null; } const { text, hash, size } = assets.getServerAsset(assetPath); const etag = `"${hash}"`; return request.headers.get('if-none-match') === etag ? new Response(undefined, { status: 304, statusText: 'Not Modified' }) : new Response(await text(), { headers: { 'Content-Length': size.toString(), 'ETag': etag, 'Content-Type': 'text/html;charset=UTF-8', ...(locale !== undefined ? { 'Content-Language': locale } : {}), ...headers } }); } async handleRendering(request, matchedRoute, requestContext) { const { renderMode, headers, status, preload } = matchedRoute; if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) { return null; } const url = new URL(request.url); const platformProviders = []; const { manifest: { bootstrap, locale }, assets } = this; const responseInit = { status, headers: new Headers({ 'Content-Type': 'text/html;charset=UTF-8', ...(locale !== undefined ? { 'Content-Language': locale } : {}), ...headers }) }; if (renderMode === RenderMode.Server) { platformProviders.push({ provide: REQUEST, useValue: request }, { provide: REQUEST_CONTEXT, useValue: requestContext }, { provide: RESPONSE_INIT, useValue: responseInit }); } else if (renderMode === RenderMode.Client) { let html = await this.assets.getServerAsset('index.csr.html').text(); html = await this.runTransformsOnHtml(html, url, preload); return new Response(html, responseInit); } if (locale !== undefined) { platformProviders.push({ provide: LOCALE_ID, useValue: locale }); } this.boostrap ??= await bootstrap(); let html = await assets.getIndexServerHtml().text(); html = await this.runTransformsOnHtml(html, url, preload); const result = await renderAngular(html, this.boostrap, url, platformProviders, SERVER_CONTEXT_VALUE[renderMode]); if (result.hasNavigationError) { return null; } if (result.redirectTo) { return createRedirectResponse(result.redirectTo, responseInit.status, responseInit.headers); } if (renderMode === RenderMode.Prerender) { const renderedHtml = await result.content(); const finalHtml = await this.inlineCriticalCss(renderedHtml, url); return new Response(finalHtml, responseInit); } const stream = new ReadableStream({ start: async controller => { const renderedHtml = await result.content(); const finalHtml = await this.inlineCriticalCssWithCache(renderedHtml, url); controller.enqueue(finalHtml); controller.close(); } }); return new Response(stream, responseInit); } async inlineCriticalCss(html, url) { const { inlineCriticalCssProcessor } = this; if (!inlineCriticalCssProcessor) { return html; } try { return await inlineCriticalCssProcessor.process(html); } catch (error) { console.error(`An error occurred while inlining critical CSS for: ${url}.`, error); return html; } } async inlineCriticalCssWithCache(html, url) { const { inlineCriticalCssProcessor, criticalCssLRUCache, textDecoder } = this; if (!inlineCriticalCssProcessor) { return textDecoder.encode(html); } const cacheKey = url.toString(); const cached = criticalCssLRUCache.get(cacheKey); const shaOfContentPreInlinedCss = await sha256(html); if (cached?.shaOfContentPreInlinedCss === shaOfContentPreInlinedCss) { return cached.contentWithCriticialCSS; } const processedHtml = await this.inlineCriticalCss(html, url); const finalHtml = textDecoder.encode(processedHtml); criticalCssLRUCache.put(cacheKey, { shaOfContentPreInlinedCss, contentWithCriticialCSS: finalHtml }); return finalHtml; } buildServerAssetPathFromRequest(request) { let { pathname: assetPath } = new URL(request.url); if (!assetPath.endsWith('/index.html')) { assetPath = joinUrlParts(assetPath, 'index.html'); } const { baseHref } = this.manifest; if (baseHref.length > 1 && assetPath.startsWith(baseHref)) { assetPath = assetPath.slice(baseHref.length); } return stripLeadingSlash(assetPath); } async runTransformsOnHtml(html, url, preload) { if (this.hooks.has('html:transform:pre')) { html = await this.hooks.run('html:transform:pre', { html, url }); } if (preload?.length) { html = appendPreloadHintsToHtml(html, preload); } return html; } } let angularServerApp; function getOrCreateAngularServerApp(options) { return angularServerApp ??= new AngularServerApp(options); } function destroyAngularServerApp() { if (typeof ngDevMode === 'undefined' || ngDevMode) { _resetCompiledComponents(); } angularServerApp = undefined; } function appendPreloadHintsToHtml(html, preload) { const bodyCloseIdx = html.lastIndexOf('</body>'); if (bodyCloseIdx === -1) { return html; } return [html.slice(0, bodyCloseIdx), ...preload.map(val => `<link rel="modulepreload" href="${val}">`), html.slice(bodyCloseIdx)].join('\n'); } function getPotentialLocaleIdFromUrl(url, basePath) { const { pathname } = url; let start = basePath.length; if (pathname[start] === '/') { start++; } let end = pathname.indexOf('/', start); if (end === -1) { end = pathname.length; } return pathname.slice(start, end); } function parseLanguageHeader(header) { 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; } return [locale, quality]; }).sort(([_localeA, qualityA], [_localeB, qualityB]) => qualityB - qualityA); return new Map(parsedValues); } function getPreferredLocale(header, supportedLocales) { if (supportedLocales.length < 2) { return supportedLocales[0]; } const parsedLocales = parseLanguageHeader(header); if (parsedLocales.size === 0 || parsedLocales.size === 1 && parsedLocales.has('*')) { return supportedLocales[0]; } const normalizedSupportedLocales = new Map(); for (const locale of supportedLocales) { normalizedSupportedLocales.set(normalizeLocale(locale), locale); } let bestMatch; const qualityZeroNormalizedLocales = new Set(); for (const [locale, quality] of parsedLocales) { const normalizedLocale = normalizeLocale(locale); if (quality === 0) { qualityZeroNormalizedLocales.add(normalizedLocale); continue; } if (normalizedSupportedLocales.has(normalizedLocale)) { return normalizedSupportedLocales.get(normalizedLocale); } if (bestMatch !== undefined) { continue; } const [languagePrefix] = normalizedLocale.split('-', 1); for (const supportedLocale of normalizedSupportedLocales.keys()) { if (supportedLocale.startsWith(languagePrefix)) { bestMatch = normalizedSupportedLocales.get(supportedLocale); break; } } } if (bestMatch !== undefined) { return bestMatch; } for (const [normalizedLocale, locale] of normalizedSupportedLocales) { if (!qualityZeroNormalizedLocales.has(normalizedLocale)) { return locale; } } } function normalizeLocale(locale) { return locale.toLowerCase(); } class AngularAppEngine { static ɵallowStaticRouteRender = false; static ɵdisableAllowedHostsCheck = false; static ɵhooks = new Hooks(); manifest = getAngularAppEngineManifest(); allowedHosts; supportedLocales = Object.keys(this.manifest.supportedLocales); trustProxyHeaders; entryPointsCache = new Map(); constructor(options) { this.allowedHosts = this.getAllowedHosts(options); this.trustProxyHeaders = normalizeTrustProxyHeaders(options?.trustProxyHeaders); } getAllowedHosts(options) { const allowedHosts = new Set([...(options?.allowedHosts ?? []), ...this.manifest.allowedHosts]); if (allowedHosts.has('*')) { console.warn('Allowing all hosts via "*" is a security risk. This configuration should only be used when ' + 'validation for "Host" and "X-Forwarded-Host" headers is performed in another layer, such as a load balancer or reverse proxy. ' + 'For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf'); } return allowedHosts; } async handle(request, requestContext) { const allowedHost = this.allowedHosts; const securedRequest = sanitizeRequestHeaders(request, this.trustProxyHeaders); try { validateRequest(securedRequest, allowedHost, AngularAppEngine.ɵdisableAllowedHostsCheck); } catch (error) { return this.handleValidationError(securedRequest.url, error); } const serverApp = await this.getAngularServerAppForRequest(securedRequest); if (serverApp) { return serverApp.handle(securedRequest, requestContext); } if (this.supportedLocales.length > 1) { return this.redirectBasedOnAcceptLanguage(securedRequest); } return null; } redirectBasedOnAcceptLanguage(request) { const { basePath, supportedLocales } = this.manifest; const { pathname } = new URL(request.url); if (pathname !== basePath) { return null; } const preferredLocale = getPreferredLocale(request.headers.get('Accept-Language') || '*', this.supportedLocales); if (preferredLocale) { const subPath = supportedLocales[preferredLocale]; if (subPath !== undefined) { const prefix = request.headers.get('X-Forwarded-Prefix') ?? ''; return createRedirectResponse(joinUrlParts(prefix, pathname, subPath), 302, { 'Vary': 'Accept-Language' }); } } return null; } async getAngularServerAppForRequest(request) { const url = new URL(request.url); const entryPoint = await this.getEntryPointExportsForUrl(url); if (!entryPoint) { return null; } const ɵgetOrCreateAngularServerApp = entryPoint.ɵgetOrCreateAngularServerApp; const serverApp = ɵgetOrCreateAngularServerApp({ allowStaticRouteRender: AngularAppEngine.ɵallowStaticRouteRender, hooks: AngularAppEngine.ɵhooks }); return serverApp; } getEntryPointExports(potentialLocale) { const cachedEntryPoint = this.entryPointsCache.get(potentialLocale); if (cachedEntryPoint) { return cachedEntryPoint; } const { entryPoints } = this.ma