| File: Manifests\EndpointsManifestTransformer.cs | Web Access |
| Project: src\src\Aspire.Hosting.Blazor\Aspire.Hosting.Blazor.csproj (Aspire.Hosting.Blazor) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; using Microsoft.Extensions.Logging; namespace Aspire.Hosting; /// <summary> /// Transforms static web asset manifests for multi-app gateway hosting: /// prefixes endpoint asset paths and merges multiple runtime manifests into one. /// </summary> internal static class EndpointsManifestTransformer { /// <summary> /// Reads an endpoints manifest and prefixes every <c>AssetFile</c> with <c>{prefix}/</c>. /// Also adds a SPA catch-all fallback endpoint cloned from the <c>index.html</c> entry. /// Routes are left unchanged — <c>MapGroup</c> handles URL prefixing at the routing level. /// </summary> public static async Task<string> PrefixEndpointsAssetFileAsync(string manifestPath, string prefix, CancellationToken ct) { var manifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false), ManifestJsonContext.Default.EndpointsManifest) ?? throw new InvalidOperationException($"Failed to deserialize endpoints manifest from '{manifestPath}'."); var fallbackEndpoints = new List<EndpointEntry>(); foreach (var ep in manifest.Endpoints) { ep.AssetFile = $"{prefix}/{ep.AssetFile}"; // Clone only the identity (uncompressed) index.html endpoint as a catch-all SPA fallback. // We skip compressed variants (those with Content-Encoding selectors) because the // ContentEncodingNegotiationMatcherPolicy would otherwise prefer the catch-all over // literal routes (like _blazor/_configuration) that lack encoding metadata. if (string.Equals(ep.Route, "index.html", StringComparison.OrdinalIgnoreCase)) { var hasContentEncoding = ep.Selectors?.Any(s => s.Name == "Content-Encoding") == true; if (!hasContentEncoding) { // Deep-clone via round-trip serialization, then patch route and cache header var fallbackJson = JsonSerializer.Serialize(ep, ManifestJsonContext.Relaxed.EndpointEntry); var fallback = JsonSerializer.Deserialize(fallbackJson, ManifestJsonContext.Default.EndpointEntry)!; fallback.Route = "{**path:nonfile}"; if (fallback.ResponseHeaders is not null) { foreach (var header in fallback.ResponseHeaders) { if (header.Name == "Cache-Control") { header.Value = "no-store"; } } } fallbackEndpoints.Add(fallback); } } } manifest.Endpoints = [.. manifest.Endpoints, .. fallbackEndpoints]; return JsonSerializer.Serialize(manifest, ManifestJsonContext.Relaxed.EndpointsManifest); } /// <summary> /// Merges multiple per-app runtime manifests into a single manifest. /// Each app's tree is wrapped under its path prefix node. <c>ContentRootIndex</c> values /// are offset for each subsequent app so they point to the correct entry in the /// combined <c>ContentRoots</c> array. /// </summary> public static async Task MergeRuntimeManifestsAsync( List<AppManifestPaths> appManifests, string outputPath, ILogger logger, CancellationToken ct) { var mergedContentRoots = new List<string>(); var mergedChildren = new Dictionary<string, AssetNode>(); foreach (var manifest in appManifests) { var reg = manifest.Registration; var runtimePath = manifest.RuntimeManifest; var prefix = reg.PathPrefix; if (!File.Exists(runtimePath)) { BlazorGatewayLog.RuntimeManifestNotFound(logger, runtimePath); continue; } var appManifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(runtimePath, ct).ConfigureAwait(false), ManifestJsonContext.Default.DevelopmentManifest)!; var offset = mergedContentRoots.Count; mergedContentRoots.AddRange(appManifest.ContentRoots); var appChildren = appManifest.Root.Children; if (offset > 0 && appChildren is not null) { foreach (var child in appChildren.Values) { child.OffsetContentRootIndices(offset); } } mergedChildren[prefix] = new AssetNode { Children = appChildren }; BlazorGatewayLog.MergedRuntimeManifest(logger, reg.Resource.Name, prefix, offset, appManifest.ContentRoots.Length); } var merged = new DevelopmentManifest { ContentRoots = [.. mergedContentRoots], Root = new AssetNode { Children = mergedChildren } }; await File.WriteAllTextAsync( outputPath, JsonSerializer.Serialize(merged, ManifestJsonContext.Relaxed.DevelopmentManifest), ct).ConfigureAwait(false); BlazorGatewayLog.WroteMergedManifest(logger, outputPath); } }