File: BlazorGatewayExtensions.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.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Dotnet;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // DockerfileBuilder is experimental
#pragma warning disable ASPIRECSHARPAPPS001 // AddCSharpApp is experimental
#pragma warning disable ASPIREDOTNETPROJECT001 // AddDotnetProject is experimental
 
namespace Aspire.Hosting;
 
/// <summary>
/// Extension methods for adding Blazor WebAssembly apps and gateway resources.
/// </summary>
[Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static class BlazorGatewayExtensions
{
    private static readonly string s_dotNetImageTag = GetDotNetImageTag();
    private const string DotNetSdkImageRepo = "mcr.microsoft.com/dotnet/sdk";
    private const string DotNetAspNetImageRepo = "mcr.microsoft.com/dotnet/aspnet";
 
    /// <summary>
    /// Registers the built-in Blazor Gateway as a file-based C# app.
    /// The gateway is shipped as Gateway.cs alongside this library and launched
    /// via <c>AddCSharpApp</c>. No separate project is needed.
    /// </summary>
    [AspireExport]
    public static IResourceBuilder<ProjectResource> AddBlazorGateway(
        this IDistributedApplicationBuilder builder,
        [ResourceName] string name)
    {
        var gatewayPath = GetScriptPath("Gateway.cs");
        var gateway = builder.AddCSharpApp(name, gatewayPath)
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        if (builder.ExecutionContext.IsPublishMode)
        {
            var gatewayDir = Path.GetDirectoryName(gatewayPath)!;
 
            gateway.PublishAsDockerFile(container =>
            {
                container.WithDockerfileBuilder(gatewayDir, ctx =>
                {
                    var logger = ctx.Services.GetService<ILogger<BlazorWasmAppResource>>();
 
                    ctx.Builder
                        .From($"{DotNetSdkImageRepo}:{s_dotNetImageTag}", "build")
                        .WorkDir("/src")
                        .Copy("Gateway.cs", ".")
                        .Run("dotnet publish Gateway.cs -c Release -o /app/publish");
 
                    ctx.Builder.AddContainerFilesStages(ctx.Resource, logger);
 
                    ctx.Builder
                        .From($"{DotNetAspNetImageRepo}:{s_dotNetImageTag}")
                        .WorkDir("/app")
                        .CopyFrom("build", "/app/publish", ".")
                        .AddContainerFiles(ctx.Resource, "/app", logger)
                        .Entrypoint(["dotnet", "Gateway.dll"]);
                });
            });
        }
 
        return gateway;
    }
 
    /// <summary>
    /// Registers the built-in Blazor Gateway backed by an experimental <see cref="DotnetProjectResource"/>
    /// from <c>Aspire.Hosting.Dotnet</c> (the run/watch-capable .NET resource), rather than the
    /// <see cref="ProjectResource"/> used by <see cref="AddBlazorGateway"/>. The gateway is shipped as
    /// Gateway.cs alongside this library and launched via <c>AddDotnetProject</c>. No separate project is needed.
    /// </summary>
    /// <remarks>
    /// Publishing is not yet supported for the <see cref="DotnetProjectResource"/>-backed gateway because the
    /// resource does not implement the container-files destination pipeline that the publish path relies on to
    /// merge each WASM client's static assets into the gateway image. Use <see cref="AddBlazorGateway"/> for
    /// publish scenarios. This restriction is expected to be lifted once container execution lands for
    /// <see cref="DotnetProjectResource"/>.
    /// </remarks>
    /// <param name="builder">The distributed application builder.</param>
    /// <param name="name">The name of the gateway resource.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for the gateway resource.</returns>
    /// <exception cref="NotSupportedException">Thrown when the application is being published.</exception>
    /// <ats-summary>Adds the built-in Blazor gateway as a run-mode .NET resource.</ats-summary>
    /// <ats-remarks>
    /// This gateway can be used only when running the AppHost. Publishing it is not supported; use the standard
    /// Blazor gateway for publish scenarios.
    /// </ats-remarks>
    /// <ats-param name="builder">The distributed application builder.</ats-param>
    /// <ats-param name="name">The name of the gateway resource.</ats-param>
    /// <ats-returns>The gateway resource builder.</ats-returns>
    [Experimental("ASPIREDOTNETPROJECT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    [AspireExport]
    public static IResourceBuilder<DotnetProjectResource> AddDotnetProjectBlazorGateway(
        this IDistributedApplicationBuilder builder,
        [ResourceName] string name)
    {
        if (builder.ExecutionContext.IsPublishMode)
        {
            // A DotnetProjectResource is an ExecutableResource and is not an IContainerFilesDestinationResource,
            // so the WASM static-asset merge that the publish path performs (via ContainerFilesDestinationAnnotation)
            // would silently produce a gateway image missing the client apps. Fail fast instead of emitting a
            // broken deployment until container execution is implemented for DotnetProjectResource.
            throw new NotSupportedException(
                $"Publishing a {nameof(DotnetProjectResource)}-backed Blazor gateway is not supported yet. Use {nameof(AddBlazorGateway)} for publish scenarios.");
        }
 
        var gatewayPath = GetScriptPath("Gateway.cs");
        return builder.AddDotnetProject(name, gatewayPath)
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
    }
 
    /// <summary>
    /// Registers a Blazor WebAssembly project as a resource using the Aspire-generated
    /// IProjectMetadata type to discover the project path. The resource name becomes the
    /// URL path prefix (e.g., "store" → served at /store/).
    /// Use WithReference() to declare service dependencies.
    /// </summary>
    [AspireExportIgnore(Reason = "Open generic type parameter TProject is not ATS-compatible.")]
    public static IResourceBuilder<BlazorWasmAppResource> AddBlazorWasmProject<TProject>(
        this IDistributedApplicationBuilder builder,
        [ResourceName] string name)
        where TProject : IProjectMetadata, new()
    {
        var metadata = new TProject();
        var projectPath = metadata.ProjectPath;
        var resource = new BlazorWasmAppResource(name, projectPath);
        return builder.AddResource(resource)
            .WithInitialState(new CustomResourceSnapshot
            {
                ResourceType = "BlazorWasmApp",
                State = KnownResourceStates.Waiting,
                Properties = [
                    new(CustomResourceKnownProperties.Source, Path.GetFileName(projectPath))
                ]
            })
            .ExcludeFromManifest();
    }
 
    /// <summary>
    /// Registers a Blazor WebAssembly project as a resource without launching it as a process.
    /// Prefer AddBlazorWasmProject&lt;TProject&gt; which uses IProjectMetadata for path discovery.
    /// </summary>
    [AspireExport("addBlazorWasmProject")]
    public static IResourceBuilder<BlazorWasmAppResource> AddBlazorWasmApp(
        this IDistributedApplicationBuilder builder,
        [ResourceName] string name,
        string projectPath)
    {
        var resolvedPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, projectPath));
        var resource = new BlazorWasmAppResource(name, resolvedPath);
        return builder.AddResource(resource)
            .WithInitialState(new CustomResourceSnapshot
            {
                ResourceType = "BlazorWasmApp",
                State = KnownResourceStates.Waiting,
                Properties = [
                    new(CustomResourceKnownProperties.Source, Path.GetFileName(resolvedPath))
                ]
            })
            .ExcludeFromManifest();
    }
 
    /// <summary>
    /// Attaches a Blazor WebAssembly app to the Gateway. The resource name is used as the
    /// URL path prefix (e.g., resource "store" → /store/). Service names are derived from
    /// WithReference() annotations on the WASM resource.
    /// Service references from the WASM app are automatically forwarded to the gateway
    /// so the gateway can resolve service endpoints for YARP proxying.
    /// </summary>
    /// <param name="gateway">The gateway resource builder.</param>
    /// <param name="wasmApp">The Blazor WebAssembly app to attach to the gateway.</param>
    /// <param name="apiPrefix">The URL path prefix for API proxy routes. Defaults to <c>"_api"</c>.</param>
    /// <param name="otlpPrefix">The URL path prefix for OTLP proxy routes. Defaults to <c>"_otlp"</c>.</param>
    /// <param name="proxyTelemetry"><see langword="true"/> to expose the OTLP proxy for the client app; otherwise, <see langword="false"/>.</param>
    [AspireExport]
    public static IResourceBuilder<ProjectResource> WithBlazorClientApp(
        this IResourceBuilder<ProjectResource> gateway,
        IResourceBuilder<BlazorWasmAppResource> wasmApp,
        string apiPrefix = GatewayConfigurationBuilder.DefaultApiPrefix,
        string otlpPrefix = GatewayConfigurationBuilder.DefaultOtlpPrefix,
        bool proxyTelemetry = true)
        => gateway.WithBlazorClientAppCore(wasmApp, apiPrefix, otlpPrefix, proxyTelemetry);
 
    /// <summary>
    /// Attaches a Blazor WebAssembly app to a <see cref="DotnetProjectResource"/>-backed Gateway created via
    /// <see cref="AddDotnetProjectBlazorGateway"/>. Behaves identically to
    /// <see cref="WithBlazorClientApp(IResourceBuilder{ProjectResource}, IResourceBuilder{BlazorWasmAppResource}, string, string, bool)"/>.
    /// </summary>
    /// <param name="gateway">The gateway resource builder.</param>
    /// <param name="wasmApp">The Blazor WebAssembly app to attach to the gateway.</param>
    /// <param name="apiPrefix">The URL path prefix for API proxy routes. Defaults to <c>"_api"</c>.</param>
    /// <param name="otlpPrefix">The URL path prefix for OTLP proxy routes. Defaults to <c>"_otlp"</c>.</param>
    /// <param name="proxyTelemetry"><see langword="true"/> to expose the OTLP proxy for the client app; otherwise, <see langword="false"/>.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for the gateway resource.</returns>
    /// <ats-summary>
    /// Attaches a Blazor WebAssembly app to the gateway. The app's resource name becomes its URL path prefix,
    /// and its service references are forwarded to the gateway for proxying.
    /// </ats-summary>
    /// <ats-param name="gateway">The gateway resource builder.</ats-param>
    /// <ats-param name="wasmApp">The Blazor WebAssembly app to attach to the gateway.</ats-param>
    /// <ats-param name="apiPrefix">The URL path prefix for API proxy routes. The default is <c>"_api"</c>.</ats-param>
    /// <ats-param name="otlpPrefix">The URL path prefix for telemetry proxy routes. The default is <c>"_otlp"</c>.</ats-param>
    /// <ats-param name="proxyTelemetry"><see langword="true"/> to expose the telemetry proxy for the client app; otherwise, <see langword="false"/>.</ats-param>
    /// <ats-returns>The gateway resource builder.</ats-returns>
    [Experimental("ASPIREDOTNETPROJECT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    [AspireExport("withDotnetProjectBlazorClientApp", MethodName = "withBlazorClientApp")]
    public static IResourceBuilder<DotnetProjectResource> WithBlazorClientApp(
        this IResourceBuilder<DotnetProjectResource> gateway,
        IResourceBuilder<BlazorWasmAppResource> wasmApp,
        string apiPrefix = GatewayConfigurationBuilder.DefaultApiPrefix,
        string otlpPrefix = GatewayConfigurationBuilder.DefaultOtlpPrefix,
        bool proxyTelemetry = true)
        => gateway.WithBlazorClientAppCore(wasmApp, apiPrefix, otlpPrefix, proxyTelemetry);
 
    private static IResourceBuilder<TGateway> WithBlazorClientAppCore<TGateway>(
        this IResourceBuilder<TGateway> gateway,
        IResourceBuilder<BlazorWasmAppResource> wasmApp,
        string apiPrefix,
        string otlpPrefix,
        bool proxyTelemetry)
        where TGateway : class, IResourceWithServiceDiscovery, IResourceWithEnvironment
    {
        var pathPrefix = wasmApp.Resource.Name;
 
        // Read endpoint references from EndpointReferenceAnnotation (added by WithReference).
        // Filter to only resources that support service discovery (i.e., actual services like weatherapi,
        // not parameters or connection strings).
        var referencedServices = GetServiceDiscoveryReferences(wasmApp.Resource);
 
        // Auto-forward service references to the gateway so YARP can resolve service endpoints
        // via Aspire's service discovery (services__{name}__{scheme}__{index} env vars).
        // Skip if the gateway already references this service. Preserve specific endpoint names
        // from the original annotation so only the intended endpoints are forwarded.
        var existingGatewayRefs = GetReferencedResourceNames(gateway.Resource);
 
        foreach (var endpointRef in referencedServices)
        {
            if (!existingGatewayRefs.Contains(endpointRef.Resource.Name))
            {
                ForwardEndpointReference(gateway, endpointRef);
            }
        }
 
        // Make the WASM app a child of the gateway so the orchestrator mirrors lifecycle
        // state (Running, Stopped, etc.) from the gateway to this resource automatically.
        wasmApp.Resource.Parent = gateway.Resource;
 
        // Build GatewayAppService instances from the endpoint reference annotations.
        var services = BuildGatewayAppServices(referencedServices);
 
        gateway.WithBlazorApp(wasmApp, pathPrefix, services, apiPrefix, otlpPrefix, proxyTelemetry);
 
        // Register browser debugging support: create a hidden child debugger resource
        // parented to the gateway, and a "Debug in Browser" command on the WASM app resource.
        if (!gateway.ApplicationBuilder.ExecutionContext.IsPublishMode)
        {
            BrowserDebuggerHelper.AddBrowserDebuggerResource(
                gateway.ApplicationBuilder,
                gateway.Resource,
                wasmApp,
                wasmApp.Resource.ProjectPath,
                relativePath: pathPrefix,
                browser: wasmApp.Resource.DebuggerBrowser);
        }
 
        return gateway;
    }
 
    /// <summary>
    /// Configures the browser launched when starting a debug session for the Blazor WebAssembly app.
    /// The value is read when the debugger is registered on the gateway, so call this before
    /// <see cref="WithBlazorClientApp(IResourceBuilder{ProjectResource}, IResourceBuilder{BlazorWasmAppResource}, string, string, bool)"/>
    /// or <see cref="WithBlazorClientApp(IResourceBuilder{DotnetProjectResource}, IResourceBuilder{BlazorWasmAppResource}, string, string, bool)"/>
    /// attaches the app.
    /// </summary>
    /// <param name="wasmApp">The Blazor WebAssembly app resource builder.</param>
    /// <param name="browser">The browser to use for debugging. Defaults to <c>"msedge"</c>. Supported values include <c>"msedge"</c> and <c>"chrome"</c>.</param>
    [AspireExport]
    public static IResourceBuilder<BlazorWasmAppResource> WithBlazorDebuggerBrowser(
        this IResourceBuilder<BlazorWasmAppResource> wasmApp,
        string browser = "msedge")
    {
        wasmApp.Resource.DebuggerBrowser = browser;
        return wasmApp;
    }
 
    /// <summary>
    /// Attaches a Blazor WebAssembly app to a Gateway project resource at the given path prefix.
    /// At orchestration time, each app is built, its manifests are discovered via MSBuild properties,
    /// transformed (AssetFile prefixed, runtime tree wrapped under prefix), then injected
    /// into the Gateway as environment variables.
    /// </summary>
    [AspireExportIgnore(Reason = "Internal open-generic implementation helper; polyglot AppHosts use the exported WithBlazorClientApp methods.")]
    internal static IResourceBuilder<TGateway> WithBlazorApp<TGateway>(
        this IResourceBuilder<TGateway> gateway,
        IResourceBuilder<BlazorWasmAppResource> wasmApp,
        string pathPrefix,
        GatewayAppService[] services,
        string apiPrefix = GatewayConfigurationBuilder.DefaultApiPrefix,
        string otlpPrefix = GatewayConfigurationBuilder.DefaultOtlpPrefix,
        bool proxyTelemetry = true)
        where TGateway : class, IResourceWithServiceDiscovery, IResourceWithEnvironment
    {
        var registration = new GatewayAppRegistration(wasmApp, pathPrefix, services, apiPrefix, otlpPrefix, proxyTelemetry);
 
        // Get or create the annotation on the gateway resource
        var annotation = GetOrAddGatewayAppsAnnotation(gateway.Resource);
 
        var gatewayOutputRoot = Path.Combine(
            GetBlazorStorePath(gateway.ApplicationBuilder), "gateways", gateway.Resource.Name);
 
        if (!annotation.IsInitialized)
        {
            annotation.IsInitialized = true;
            MirrorGatewayStateToClients(gateway);
 
            gateway.WithEnvironment(async context =>
            {
                var registeredApps = GetRegisteredApps(gateway.Resource);
                var httpsGatewayEndpoint = GetEndpointIfDefined(gateway.Resource, "https");
                var httpGatewayEndpoint = GetEndpointIfDefined(gateway.Resource, "http");
                var gatewayEndpoint = httpsGatewayEndpoint ?? httpGatewayEndpoint
                    ?? throw new InvalidOperationException($"The gateway '{gateway.Resource.Name}' must define an HTTP or HTTPS endpoint.");
 
                // Resolve the HTTP OTLP endpoint for WASM client proxying.
                // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint.
                // First try to resolve from the dashboard resource model (handles randomized ports
                // and isolated mode). Fall back to configuration for cases where the dashboard
                // resource isn't in the model (e.g. external dashboard).
                var httpOtlpEndpointUrl = ResolveHttpOtlpEndpointUrl(context, gateway.ApplicationBuilder.Configuration);
 
                if (httpOtlpEndpointUrl is null && registeredApps.Any(a => a.ProxyBlazorTelemetry))
                {
                    context.Logger.LogWarning(
                        "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " +
                        "WASM client telemetry will not be forwarded.");
                }
 
                if (context.ExecutionContext.IsPublishMode)
                {
                    ConfigurePublishEnvironment(context, registeredApps, gatewayEndpoint, httpGatewayEndpoint);
                    return;
                }
 
                // Clean up stale output from previous runs (but preserve the scripts subdir).
                var outputDir = Path.Combine(gatewayOutputRoot, "output");
                if (Directory.Exists(outputDir))
                {
                    Directory.Delete(outputDir, recursive: true);
                }
 
                Directory.CreateDirectory(outputDir);
 
                var manifests = await BuildAndDiscoverManifestsAsync(registeredApps, context.Logger, context.CancellationToken).ConfigureAwait(false);
                if (manifests == null)
                {
                    return;
                }
 
                if (!await PrefixAndWriteEndpointsAsync(manifests, outputDir, context).ConfigureAwait(false))
                {
                    return;
                }
 
                var mergedRuntimePath = Path.Combine(outputDir, "merged.staticwebassets.runtime.json");
                await EndpointsManifestTransformer.MergeRuntimeManifestsAsync(manifests, mergedRuntimePath, context.Logger, context.CancellationToken).ConfigureAwait(false);
                context.EnvironmentVariables["staticWebAssets"] = mergedRuntimePath;
 
                GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl);
            });
        }
 
        annotation.Apps.Add(registration);
 
        if (gateway.ApplicationBuilder.ExecutionContext.IsPublishMode)
        {
            CreatePublishCompanion(gateway, wasmApp, pathPrefix);
        }
 
        return gateway;
    }
 
    private static ProjectInfo GetProjectInfo(string projectPath, string appHostDirectory)
    {
        var projectDir = Path.GetDirectoryName(projectPath)!;
        var solutionRoot = Path.GetFullPath(Path.Combine(appHostDirectory, ".."));
        var relativeProjectPath = Path.GetRelativePath(solutionRoot, projectDir)
            .Replace('\\', '/');
        return new ProjectInfo(solutionRoot, relativeProjectPath);
    }
 
    private static void MirrorGatewayStateToClients<TGateway>(IResourceBuilder<TGateway> gateway)
        where TGateway : class, IResource
    {
        // Subscribe to the gateway's InitializeResourceEvent to start a background watcher
        // that mirrors state changes from the gateway to all registered WASM app resources.
        // This mirrors the pattern used by ApplicationOrchestrator.SetChildResourceAsync for
        // container children, but uses ResourceNotificationService.WatchAsync since the
        // orchestrator does not propagate state for the gateway resource's parents.
        gateway.ApplicationBuilder.Eventing.Subscribe<InitializeResourceEvent>(gateway.Resource, (e, ct) =>
        {
            var notificationService = e.Notifications;
            _ = Task.Run(() => WatchGatewayStateAsync(gateway.Resource, notificationService, ct), ct);
            return Task.CompletedTask;
        });
    }
 
    private static async Task WatchGatewayStateAsync<TGateway>(
        TGateway gateway,
        ResourceNotificationService notificationService,
        CancellationToken cancellationToken)
        where TGateway : class, IResource
    {
        await foreach (var resourceEvent in notificationService.WatchAsync(cancellationToken).ConfigureAwait(false))
        {
            if (resourceEvent.Resource != gateway)
            {
                continue;
            }
 
            var registeredApps = GetRegisteredApps(gateway);
            var gatewayState = resourceEvent.Snapshot.State;
 
            var isRunning = gatewayState?.Text == KnownResourceStates.Running;
 
            foreach (var reg in registeredApps)
            {
                var clientUrls = isRunning
                    ? BuildClientUrls(GetAllocatedEndpoints(gateway), reg.PathPrefix)
                    : [];
 
                await notificationService.PublishUpdateAsync(reg.AppBuilder.Resource, snapshot => snapshot with
                {
                    State = gatewayState,
                    StartTimeStamp = resourceEvent.Snapshot.StartTimeStamp,
                    StopTimeStamp = resourceEvent.Snapshot.StopTimeStamp,
                    Urls = clientUrls
                }).ConfigureAwait(false);
            }
        }
    }
 
    private static void ConfigurePublishEnvironment(
        EnvironmentCallbackContext context,
        List<GatewayAppRegistration> apps,
        EndpointReference gatewayEndpoint,
        EndpointReference? httpGatewayEndpoint)
    {
        foreach (var reg in apps)
        {
            var envPrefix = $"ClientApps__{reg.Resource.Name}";
            context.EnvironmentVariables[$"{envPrefix}__PathPrefix"] = reg.PathPrefix;
            context.EnvironmentVariables[$"{envPrefix}__EndpointsManifest"] = $"/app/{reg.PathPrefix}.endpoints.json";
            context.EnvironmentVariables[$"{envPrefix}__ConfigEndpointPath"] = $"{reg.PathPrefix}/_blazor/_configuration";
        }
 
        GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, apps, gatewayEndpoint, httpGatewayEndpoint);
    }
 
    private static async Task<List<AppManifestPaths>?> BuildAndDiscoverManifestsAsync(
        List<GatewayAppRegistration> apps, ILogger logger, CancellationToken ct)
    {
        var result = new List<AppManifestPaths>();
 
        foreach (var reg in apps)
        {
            var success = await BlazorWasmAppBuilder.BuildAsync(reg.Resource.ProjectPath, logger, ct).ConfigureAwait(false);
            if (!success)
            {
                BlazorGatewayLog.FailedToBuild(logger, reg.Resource.Name);
                return null;
            }
 
            var paths = await BlazorWasmAppBuilder.GetManifestPathsAsync(reg.Resource.ProjectPath, logger, ct).ConfigureAwait(false);
            if (paths == null)
            {
                BlazorGatewayLog.FailedToResolveManifests(logger, reg.Resource.Name);
                return null;
            }
 
            result.Add(new AppManifestPaths(reg, paths.Value.endpointsManifest, paths.Value.runtimeManifest));
            BlazorGatewayLog.DiscoveredManifests(logger,
                reg.Resource.Name, paths.Value.endpointsManifest, paths.Value.runtimeManifest);
        }
 
        return result;
    }
 
    private static async Task<bool> PrefixAndWriteEndpointsAsync(
        List<AppManifestPaths> manifests, string outputDir, EnvironmentCallbackContext context)
    {
        foreach (var manifest in manifests)
        {
            var reg = manifest.Registration;
            var srcEndpoints = manifest.EndpointsManifest;
 
            if (!File.Exists(srcEndpoints))
            {
                BlazorGatewayLog.EndpointsManifestNotFound(context.Logger, srcEndpoints);
                return false;
            }
 
            var modifiedEndpoints = await EndpointsManifestTransformer.PrefixEndpointsAssetFileAsync(
                srcEndpoints, reg.PathPrefix, context.CancellationToken).ConfigureAwait(false);
            var destEndpoints = Path.Combine(outputDir, $"{reg.Resource.Name}.endpoints.json");
            await File.WriteAllTextAsync(destEndpoints, modifiedEndpoints, context.CancellationToken).ConfigureAwait(false);
 
            BlazorGatewayLog.WrotePrefixedEndpoints(context.Logger, reg.Resource.Name, destEndpoints);
 
            var envPrefix = $"ClientApps__{reg.Resource.Name}";
            context.EnvironmentVariables[$"{envPrefix}__PathPrefix"] = reg.PathPrefix;
            context.EnvironmentVariables[$"{envPrefix}__EndpointsManifest"] = destEndpoints;
            context.EnvironmentVariables[$"{envPrefix}__ConfigEndpointPath"] = $"{reg.PathPrefix}/_blazor/_configuration";
        }
 
        return true;
    }
 
    private static void CreatePublishCompanion<TGateway>(
        IResourceBuilder<TGateway> gateway,
        IResourceBuilder<BlazorWasmAppResource> wasmApp,
        string pathPrefix)
        where TGateway : class, IResource
    {
        var publishResourceName = $"{wasmApp.Resource.Name}publish";
        var project = GetProjectInfo(wasmApp.Resource.ProjectPath, gateway.ApplicationBuilder.AppHostDirectory);
        var relativeProjectPath = Path.GetRelativePath(
            project.SolutionRoot, wasmApp.Resource.ProjectPath).Replace('\\', '/');
 
        // Copy PrefixEndpoints.cs into .aspire/scripts/ within the solution root so it's
        // included in the Docker build context.
        var scriptSource = GetScriptPath("PrefixEndpoints.cs");
        var scriptDest = Path.Combine(project.SolutionRoot, ".aspire", "scripts", "PrefixEndpoints.cs");
        Directory.CreateDirectory(Path.GetDirectoryName(scriptDest)!);
        File.Copy(scriptSource, scriptDest, overwrite: true);
        var scriptRelativePath = Path.GetRelativePath(project.SolutionRoot, scriptDest)
            .Replace('\\', '/');
 
        var companion = gateway.ApplicationBuilder.AddResource(
            new BlazorWasmPublishResource(publishResourceName))
            .WithImage("placeholder")
            .WithContainerFilesSource("/app/output");
 
        companion.WithDockerfileFactory(project.SolutionRoot, ctx =>
        {
            return $$"""
                FROM {{DotNetSdkImageRepo}}:{{s_dotNetImageTag}} AS build
                WORKDIR /src
                COPY . .
                RUN dotnet publish "{{relativeProjectPath}}" -c Release -o /app/publish
 
                # Prefix asset paths and add SPA fallback endpoint
                RUN mkdir -p /app/output/wwwroot/{{pathPrefix}} && \
                    cp -r /app/publish/wwwroot/* /app/output/wwwroot/{{pathPrefix}}/ && \
                    dotnet run "{{scriptRelativePath}}" -- \
                        /app/publish/*.staticwebassets.endpoints.json \
                        {{pathPrefix}} \
                        /app/output/{{pathPrefix}}.endpoints.json
                """;
        });
 
        gateway.WithAnnotation(new ContainerFilesDestinationAnnotation
        {
            Source = companion.Resource,
            DestinationPath = "."
        });
    }
 
    private static string GetScriptPath(string scriptName)
    {
        var assemblyDir = Path.GetDirectoryName(typeof(BlazorGatewayExtensions).Assembly.Location)!;
        var scriptPath = Path.Combine(assemblyDir, "Scripts", scriptName);
 
        if (!File.Exists(scriptPath))
        {
            throw new FileNotFoundException(
                $"{scriptName} not found at '{scriptPath}'. Ensure the Aspire.Hosting.Blazor package includes the file as content.");
        }
 
        return scriptPath;
    }
 
    private const string AspireStorePathKey = "Aspire:Store:Path";
 
    /// <summary>
    /// Gets the Blazor-specific store path under the Aspire store directory.
    /// </summary>
    private static string GetBlazorStorePath(IDistributedApplicationBuilder builder)
    {
        var storePath = builder.Configuration[AspireStorePathKey]
            ?? builder.AppHostDirectory;
 
        return Path.Combine(storePath, ".aspire", "blazor");
    }
 
    private static List<EndpointReferenceAnnotation> GetServiceDiscoveryReferences(IResource resource)
    {
        // EndpointReferenceAnnotation is added by WithReference and tracks which endpoint
        // resources are referenced and which specific endpoint names were requested.
        return resource.Annotations
            .OfType<EndpointReferenceAnnotation>()
            .Where(a => a.Resource is IResourceWithServiceDiscovery)
            .ToList();
    }
 
    /// <summary>
    /// Builds <see cref="GatewayAppService"/> instances from endpoint reference annotations.
    /// Each service carries its resource name and any specific endpoint names referenced.
    /// </summary>
    private static GatewayAppService[] BuildGatewayAppServices(List<EndpointReferenceAnnotation> references)
    {
        var services = new GatewayAppService[references.Count];
 
        for (var i = 0; i < references.Count; i++)
        {
            var annotation = references[i];
            var service = new GatewayAppService(annotation.Resource.Name);
 
            if (!annotation.UseAllEndpoints)
            {
                foreach (var endpointName in annotation.EndpointNames)
                {
                    service.EndpointNames.Add(endpointName);
                }
            }
 
            services[i] = service;
        }
 
        return services;
    }
 
    private static HashSet<string> GetReferencedResourceNames(IResource resource)
    {
        return resource.Annotations
            .OfType<EndpointReferenceAnnotation>()
            .Select(a => a.Resource.Name)
            .ToHashSet(StringComparers.ResourceName);
    }
 
    /// <summary>
    /// Forwards an endpoint reference to the gateway. When specific named endpoints are
    /// referenced, each one is forwarded individually (YARP uses the named endpoint format
    /// <c>https+http://_endpointName.serviceName</c>). When all endpoints are referenced,
    /// all endpoints are forwarded so YARP can resolve by scheme.
    /// </summary>
    private static void ForwardEndpointReference<TGateway>(
        IResourceBuilder<TGateway> gateway,
        EndpointReferenceAnnotation endpointRef)
        where TGateway : class, IResourceWithEnvironment
    {
        var svcResource = (IResourceWithServiceDiscovery)endpointRef.Resource;
 
        if (!endpointRef.UseAllEndpoints)
        {
            // Forward each specific named endpoint. YARP will resolve via
            // https+http://_endpointName.serviceName using these entries.
            foreach (var endpointName in endpointRef.EndpointNames)
            {
                gateway.WithReference(svcResource.GetEndpoint(endpointName));
            }
        }
        else
        {
            // Forward all endpoints so scheme-based resolution works.
            var svcBuilder = gateway.ApplicationBuilder.CreateResourceBuilder(svcResource);
            gateway.WithReference(svcBuilder);
        }
    }
 
    private static EndpointReference? GetEndpointIfDefined(IResourceWithEndpoints resource, string endpointName)
    {
        var endpoint = resource.GetEndpoint(endpointName);
        return endpoint.Exists ? endpoint : null;
    }
 
    private static GatewayAppsAnnotation GetOrAddGatewayAppsAnnotation(IResource resource)
    {
        if (resource.TryGetLastAnnotation<GatewayAppsAnnotation>(out var existing))
        {
            return existing;
        }
 
        var newAnnotation = new GatewayAppsAnnotation();
        resource.Annotations.Add(newAnnotation);
        return newAnnotation;
    }
 
    private static List<GatewayAppRegistration> GetRegisteredApps(IResource resource)
    {
        if (resource.TryGetLastAnnotation<GatewayAppsAnnotation>(out var apps))
        {
            return apps.Apps;
        }
 
        throw new InvalidOperationException("GatewayAppsAnnotation not found on resource.");
    }
 
    private static List<EndpointAnnotation> GetAllocatedEndpoints(IResource resource)
    {
        var endpoints = new List<EndpointAnnotation>();
        foreach (var annotation in resource.Annotations)
        {
            if (annotation is EndpointAnnotation ep && ep.AllocatedEndpoint is not null)
            {
                endpoints.Add(ep);
            }
        }
        return endpoints;
    }
 
    private static ImmutableArray<UrlSnapshot> BuildClientUrls(
        List<EndpointAnnotation> endpoints, string pathPrefix)
    {
        var builder = ImmutableArray.CreateBuilder<UrlSnapshot>(endpoints.Count);
        foreach (var ep in endpoints)
        {
            builder.Add(new UrlSnapshot(
                Name: ep.Name,
                Url: $"{ep.AllocatedEndpoint!.UriString}/{pathPrefix}",
                IsInternal: false));
        }
        return builder.MoveToImmutable();
    }
 
    /// <summary>
    /// Resolves the HTTP OTLP endpoint for proxying browser telemetry to the dashboard.
    /// Tries the dashboard resource model first (handles randomized ports), then falls back
    /// to well-known configuration keys for cases where the dashboard isn't in the model
    /// (e.g. external or standalone dashboard).
    /// </summary>
    internal static object? ResolveHttpOtlpEndpointUrl(EnvironmentCallbackContext context, IConfiguration configuration)
    {
        DistributedApplicationModel? model;
        try
        {
            model = context.ExecutionContext.Services.GetService<DistributedApplicationModel>();
        }
        catch (InvalidOperationException)
        {
            // ServiceProvider may not be available if the container hasn't been built yet.
            model = null;
        }
 
        if (model is not null
            && model.Resources.TryGetByName("aspire-dashboard", out var resource)
            && resource is IResourceWithEndpoints dashboardResource)
        {
            var httpEndpoint = dashboardResource.GetEndpoint("otlp-http");
            if (httpEndpoint.Exists)
            {
                return httpEndpoint;
            }
        }
 
        // Fall back to configuration for external dashboard scenarios.
        return (object?)configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"]
            ?? configuration["DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"];
    }
 
    private readonly struct ProjectInfo(string solutionRoot, string relativeProjectPath)
    {
        public string SolutionRoot { get; } = solutionRoot;
        public string RelativeProjectPath { get; } = relativeProjectPath;
    }
 
    /// <summary>
    /// Resolves the Docker image tag for .NET base images. Uses the maximum of the build-time
    /// stamped version and the actual runtime version, with pre-release suffix when applicable.
    /// </summary>
    private static string GetDotNetImageTag()
    {
        var runtimeMajor = Environment.Version.Major;
        var runtimeMinor = Environment.Version.Minor;
 
        var stampedMajor = runtimeMajor;
        var stampedMinor = runtimeMinor;
 
        var stampedValue = typeof(BlazorGatewayExtensions).Assembly
            .GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), inherit: false)
            .OfType<System.Reflection.AssemblyMetadataAttribute>()
            .FirstOrDefault(a => a.Key == "BlazorGatewayDotNetImageTag")
            ?.Value;
 
        if (!string.IsNullOrEmpty(stampedValue))
        {
            var parts = stampedValue.Split('.');
            if (parts.Length >= 2
                && int.TryParse(parts[0], out var sMajor)
                && int.TryParse(parts[1], out var sMinor))
            {
                stampedMajor = sMajor;
                stampedMinor = sMinor;
            }
        }
 
        var major = Math.Max(runtimeMajor, stampedMajor);
        var minor = (major == runtimeMajor && major == stampedMajor)
            ? Math.Max(runtimeMinor, stampedMinor)
            : (major == runtimeMajor ? runtimeMinor : stampedMinor);
 
        var tag = $"{major}.{minor}";
 
        // Append pre-release suffix when the runtime version won and is pre-release.
        if (major == runtimeMajor && minor == runtimeMinor)
        {
            var informationalVersion = (System.Reflection.AssemblyInformationalVersionAttribute?)
                Attribute.GetCustomAttribute(typeof(object).Assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute));
 
            if (informationalVersion is not null)
            {
                if (informationalVersion.InformationalVersion.Contains("-preview", StringComparison.OrdinalIgnoreCase))
                {
                    tag += "-preview";
                }
                else if (informationalVersion.InformationalVersion.Contains("-rc", StringComparison.OrdinalIgnoreCase))
                {
                    tag += "-rc";
                }
            }
        }
 
        return tag;
    }
}