File: Backchannel\AppHostRpcTarget.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Devcontainers.Codespaces;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Backchannel;
 
internal class AppHostRpcTarget(
    ILogger<AppHostRpcTarget> logger,
    ResourceNotificationService resourceNotificationService,
    IServiceProvider serviceProvider,
    IDistributedApplicationEventing eventing,
    PublishingActivityProgressReporter activityReporter,
    IHostApplicationLifetime lifetime
    ) 
{
    public async IAsyncEnumerable<(string Id, string StatusText, bool IsComplete, bool IsError)> GetPublishingActivitiesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
    {
        while (cancellationToken.IsCancellationRequested == false)
        {
            var publishingActivityStatus = await activityReporter.ActivityStatusUpdated.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
 
            if (publishingActivityStatus == null)
            {
                // If the publishing activity is null, it means that the activity has been removed.
                // This can happen if the activity is complete or an error occurred.
                yield break;
            }
 
            yield return (
                publishingActivityStatus.Activity.Id,
                publishingActivityStatus.StatusText,
                publishingActivityStatus.IsComplete,
                publishingActivityStatus.IsError
            );
 
            if ( publishingActivityStatus.Activity.IsPrimary &&(publishingActivityStatus.IsComplete || publishingActivityStatus.IsError))
            {
                // If the activity is complete or an error and it is the primary activity,
                // we can stop listening for updates.
                yield break;
            }
        }
    }
 
    public async IAsyncEnumerable<(string Resource, string Type, string State, string[] Endpoints)> GetResourceStatesAsync([EnumeratorCancellation]CancellationToken cancellationToken)
    {
        var resourceEvents = resourceNotificationService.WatchAsync(cancellationToken);
 
        await foreach (var resourceEvent in resourceEvents.WithCancellation(cancellationToken).ConfigureAwait(false))
        {
            if (resourceEvent.Resource.Name == "aspire-dashboard")
            {
                // Skip the dashboard resource, as it is handled separately.
                continue;
            }
 
            if (!resourceEvent.Resource.TryGetEndpoints(out var endpoints))
            {
                logger.LogTrace("Resource {Resource} does not have endpoints.", resourceEvent.Resource.Name);
                endpoints = Enumerable.Empty<EndpointAnnotation>();
            }
    
            var endpointUris = endpoints
                .Where(e => e.AllocatedEndpoint != null)
                .Select(e => e.AllocatedEndpoint!.UriString)
                .ToArray();
            // TODO: Decide on whether we want to define a type and share it between codebases for this.
            yield return (
                resourceEvent.Resource.Name,
                resourceEvent.Snapshot.ResourceType,
                resourceEvent.Snapshot.State?.Text ?? "Unknown",
                endpointUris
                );
        }
    }
 
    public Task RequestStopAsync(CancellationToken cancellationToken)
    {
        _ = cancellationToken;
        lifetime.StopApplication();
        return Task.CompletedTask;
    }
 
    public Task<long> PingAsync(long timestamp, CancellationToken cancellationToken)
    {
        _ = cancellationToken;
        logger.LogTrace("Received ping from CLI with timestamp: {Timestamp}", timestamp);
        return Task.FromResult(timestamp);
    }
 
    public Task<(string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken)> GetDashboardUrlsAsync()
    {
        var dashboardOptions = serviceProvider.GetService<IOptions<DashboardOptions>>();
 
        if (dashboardOptions is null)
        {
            logger.LogWarning("Dashboard options not found.");
            throw new InvalidOperationException("Dashboard options not found.");
        }
 
        if (!StringUtils.TryGetUriFromDelimitedString(dashboardOptions.Value.DashboardUrl, ";", out var dashboardUri))
        {
            logger.LogWarning("Dashboard URL could not be parsed from dashboard options.");
            throw new InvalidOperationException("Dashboard URL could not be parsed from dashboard options.");            
        }
 
        var codespacesUrlRewriter = serviceProvider.GetService<CodespacesUrlRewriter>();
 
        var baseUrlWithLoginToken = $"{dashboardUri.GetLeftPart(UriPartial.Authority)}/login?t={dashboardOptions.Value.DashboardToken}";
        var codespacesUrlWithLoginToken = codespacesUrlRewriter?.RewriteUrl(baseUrlWithLoginToken);
 
        if (baseUrlWithLoginToken == codespacesUrlWithLoginToken)
        {
            return Task.FromResult<(string, string?)>((baseUrlWithLoginToken, null));
        }
        else
        {
            return Task.FromResult((baseUrlWithLoginToken, codespacesUrlWithLoginToken));
        }
    }
 
    public async Task<string[]> GetPublishersAsync(CancellationToken cancellationToken)
    {
        var e = new PublisherAdvertisementEvent();
        await eventing.PublishAsync(e, cancellationToken).ConfigureAwait(false);
 
        var publishers = e.Advertisements.Select(x => x.Name);
        return [..publishers];
    }
 
#pragma warning disable CA1822
    public Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken)
    {
        // The purpose of this API is to allow the CLI to determine what API surfaces
        // the AppHost supports. In 9.2 we'll be saying that you need a 9.2 apphost,
        // but the 9.3 CLI might actually support working with 9.2 apphosts. The idea
        // is that when the backchannel is established the CLI will call this API
        // and store the results. The "baseline.v0" capability is the bare minimum
        // that we need as of CLI version 9.2-preview*.
        //
        // Some capabilties will be opt in. For example in 9.3 we might refine the
        // publishing activities API to return more information, or add log streaming
        // features. So that would add a new capability that the apphsot can report
        // on initial backchannel negotiation and the CLI can adapt its behavior around
        // that. There may be scenarios where we need to break compataiblity at which
        // point we might increase the baseline version that the apphost reports.
        //
        // The ability to support a back channel at all is determined by the CLI by
        // making sure that the apphost version is at least > 9.2.
 
        _ = cancellationToken;
        return Task.FromResult(new string[] {
            "baseline.v0"
            });
    }
#pragma warning restore CA1822
}