File: Devcontainers\Codespaces\CodespacesResourceUrlRewriterService.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.Collections.Immutable;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Devcontainers.Codespaces;
 
internal sealed class CodespacesResourceUrlRewriterService(ILogger<CodespacesResourceUrlRewriterService> logger, IOptions<CodespacesOptions> options, CodespacesUrlRewriter codespaceUrlRewriter, ResourceNotificationService resourceNotificationService) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        if (!options.Value.IsCodespace)
        {
            logger.LogTrace("Not running in Codespaces, skipping URL rewriting.");
            return;
        }
 
        do
        {
            try
            {
                var resourceEvents = resourceNotificationService.WatchAsync(stoppingToken);
 
                await foreach (var resourceEvent in resourceEvents.ConfigureAwait(false))
                {
                    Dictionary<UrlSnapshot, UrlSnapshot>? remappedUrls = null;
 
                    foreach (var originalUrlSnapshot in resourceEvent.Snapshot.Urls)
                    {
                        var uri = new Uri(originalUrlSnapshot.Url);
 
                        if (!originalUrlSnapshot.IsInternal && (uri.Scheme == "http" || uri.Scheme == "https") && uri.Host == "localhost")
                        {
                            remappedUrls ??= new();
 
                            var newUrlSnapshot = originalUrlSnapshot with
                            {
                                // The format of GitHub Codespaces URLs comprises the codespace
                                // name (from the CODESPACE_NAME environment variable, the port,
                                // and the port forwarding domain (via GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN
                                // which is typically ".app.github.dev". The VSCode instance is typically
                                // hosted at codespacename.github.dev whereas the forwarded ports
                                // would be at codespacename-port.app.github.dev.
                                Url = codespaceUrlRewriter.RewriteUrl(uri)
                            };
 
                            remappedUrls.Add(originalUrlSnapshot, newUrlSnapshot);
                        }
                    }
 
                    if (remappedUrls is not null)
                    {
                        var transformedUrls = from originalUrl in resourceEvent.Snapshot.Urls
                                              select remappedUrls.TryGetValue(originalUrl, out var remappedUrl) ? remappedUrl : originalUrl;
 
                        await resourceNotificationService.PublishUpdateAsync(resourceEvent.Resource, resourceEvent.ResourceId, s => s with
                        {
                            Urls = transformedUrls.ToImmutableArray()
                        }).ConfigureAwait(false);
                    }
                }
            }
            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
            {
                // When debugging sometimes we'll get cancelled here but we don't want
                // to tear down the loop. We only want to crash out when the service's
                // cancellation token is signaled.
                logger.LogTrace(ex, "Codespace URL rewriting loop threw an exception but was ignored.");
            }
        } while (!stoppingToken.IsCancellationRequested);
    }
}