File: DevTunnelOutputParser.cs
Web Access
Project: src\src\Aspire.Hosting.DevTunnels\Aspire.Hosting.DevTunnels.csproj (Aspire.Hosting.DevTunnels)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text.RegularExpressions;
 
namespace Aspire.Hosting.DevTunnels;
 
/// <summary>
/// Recognizes host lifecycle messages without depending on console layout or separator punctuation.
/// </summary>
internal sealed partial class DevTunnelOutputParser(string tunnelId)
{
    private const int MaxPendingLength = 4096;
    private string? _pending;
    private int? _port;
    private bool _inspectUrl;
    private bool _readyWithoutId;
 
    // Color sequences can sit between a separator and the next message, such as:
    //   Connection ... restored.; \x1b[31mConnection ... closed.\x1b[0m
    // Remove them before detecting boundaries, not only when parsing each resulting message.
    internal static string[] SplitOutput(string content) => MessageBoundaryRegex().Split(AnsiRegex().Replace(content, ""));
 
    public Output Parse(string line)
    {
        // Observed CLI output (including a format documented by earlier versions):
        //   Hosting port: 7007
        //   Connect via browser: https://abc-7007.usw2.devtunnels.ms
        //   Inspect network activity: https://abc-7007-inspect.usw2.devtunnels.ms
        //   Hosting port 7007 at https://abc-7007.usw2.devtunnels.ms/
        // Labels and values can arrive on separate lines/batches. Keep only a bounded,
        // recognized prefix, never an arbitrary tail of unrecognized console output.
        var text = WhitespaceRegex().Replace(AnsiRegex().Replace(line, ""), " ").Trim();
        if (text.Length == 0)
        {
            return default;
        }
 
        var words = WordsRegex().Replace(text, " ").Trim().ToLowerInvariant();
        if (_pending is not null && StartsMessage(words))
        {
            // For example, "Inspect network activity:" may be abandoned before
            // "Connection to host tunnel relay closed." arrives. New messages must supersede
            // unfinished fields, especially disconnects; only actual continuations are joined.
            _pending = null;
            _port = null;
            _inspectUrl = false;
            _readyWithoutId = false;
        }
        if (_pending is { } pending)
        {
            text = pending + " " + text;
            words = WordsRegex().Replace(text, " ").Trim().ToLowerInvariant();
            _pending = null;
        }
 
        if (_readyWithoutId && words.StartsWith("for tunnel", StringComparison.Ordinal))
        {
            text = "Ready to accept connections " + text;
            words = "ready to accept connections " + words;
        }
        _readyWithoutId = false;
        if (words.StartsWith("hosting port ", StringComparison.Ordinal))
        {
            var match = PortRegex().Match(text);
            if (!match.Success || !int.TryParse(match.Groups["port"].ValueSpan, CultureInfo.InvariantCulture, out var port) || port is < 1 or > 65535)
            {
                return Unknown();
            }
 
            _port = port;
            _inspectUrl = false;
            var urlMatch = UrlRegex().Match(text);
            return urlMatch.Success ? ParseUrl(urlMatch.Value) : default;
        }
 
        if (words.StartsWith("connect via browser ", StringComparison.Ordinal)
            || words.StartsWith("inspect network activity ", StringComparison.Ordinal)
            || (_port is not null && words.StartsWith("at ", StringComparison.Ordinal))
            || Uri.TryCreate(text.Trim('<', '>', '(', ')'), UriKind.Absolute, out _))
        {
            _inspectUrl |= words.StartsWith("inspect network activity", StringComparison.Ordinal);
            var match = UrlRegex().Match(text);
            return match.Success ? ParseUrl(match.Value) : Unknown();
        }
 
        if (words.StartsWith("ready to accept connections", StringComparison.Ordinal))
        {
            if (words.EndsWith("for tunnel", StringComparison.Ordinal))
            {
                return Remember(text);
            }
 
            var match = ReadyRegex().Match(text);
            if (match.Success)
            {
                var reportedId = match.Groups["id"].Value;
                if (reportedId.Length == 0
                    || string.Equals(reportedId, tunnelId, StringComparison.OrdinalIgnoreCase)
                    || reportedId.StartsWith(tunnelId + ".", StringComparison.OrdinalIgnoreCase))
                {
                    _readyWithoutId = reportedId.Length == 0;
                    return new(OutputKind.Ready);
                }
            }
 
            return Unknown();
        }
 
        if (words.StartsWith("connection to host tunnel relay ", StringComparison.Ordinal))
        {
            var state = words["connection to host tunnel relay ".Length..].Split(' ')[0];
            return state switch
            {
                "restored" or "connected" => new(OutputKind.Connected),
                "closed" or "lost" or "disconnected" => new(OutputKind.Disconnected),
                _ => Unknown()
            };
        }
 
        if (IsPrefix(words, "hosting port")
            || IsPrefix(words, "connect via browser")
            || IsPrefix(words, "inspect network activity")
            || IsPrefix(words, "ready to accept connections for tunnel")
            || IsPrefix(words, "connection to host tunnel relay"))
        {
            return Remember(text);
        }
 
        return Unknown();
    }
 
    internal static bool IsPortUri(Uri uri, int port)
    {
        return uri.IsAbsoluteUri
            && uri.Scheme == Uri.UriSchemeHttps
            && uri.IsDefaultPort
            && uri.UserInfo.Length == 0
            && uri.Query.Length == 0
            && uri.Fragment.Length == 0
            && uri.AbsolutePath == "/"
            && uri.Host.EndsWith(".devtunnels.ms", StringComparison.OrdinalIgnoreCase)
            && uri.Host.Split('.')[0].EndsWith("-" + port.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
    }
 
    private Output ParseUrl(string text)
    {
        text = text.TrimEnd('.', ',', ';', ':', ')', ']', '>');
        if (_inspectUrl)
        {
            _inspectUrl = false;
            return default;
        }
 
        if (_port is { } port && Uri.TryCreate(text, UriKind.Absolute, out var uri) && IsPortUri(uri, port))
        {
            _port = null;
            return new(OutputKind.Port, port, uri);
        }
 
        return Unknown();
    }
 
    private Output Remember(string text)
    {
        if (text.Length > MaxPendingLength)
        {
            return Unknown();
        }
 
        _pending = text;
        return default;
    }
 
    private Output Unknown()
    {
        _pending = null;
        _port = null;
        _inspectUrl = false;
        return new(OutputKind.Unrecognized);
    }
 
    private static bool IsPrefix(string text, string phrase) =>
        phrase.Equals(text, StringComparison.Ordinal) || phrase.StartsWith(text + " ", StringComparison.Ordinal);
 
    private static bool StartsMessage(string words)
    {
        var separator = words.IndexOf(' ');
        var firstWord = separator < 0 ? words : words[..separator];
        return firstWord is "hosting" or "connect" or "inspect" or "ready" or "connection";
    }
 
    [GeneratedRegex(@"\s+")]
    private static partial Regex WhitespaceRegex();
 
    [GeneratedRegex(@"[\r\n]+|(?<=[\s;|])(?=(?:hosting\s+port|connect\s+via\s+browser|inspect\s+network\s+activity|ready\s+to\s+accept\s+connections|connection\s+to\s+host\s+tunnel\s+relay)\b)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex MessageBoundaryRegex();
 
    [GeneratedRegex(@"[\s\p{P}\p{S}]+")]
    private static partial Regex WordsRegex();
 
    [GeneratedRegex(@"\x1b\[[0-?]*[ -/]*[@-~]")]
    private static partial Regex AnsiRegex();
 
    [GeneratedRegex(@"^hosting[\s\p{P}\p{S}]+port[\s\p{P}\p{S}]*(?<port>\d+)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex PortRegex();
 
    [GeneratedRegex(@"https?://[^\s<>""'`]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex UrlRegex();
 
    [GeneratedRegex(@"^ready[\s\p{P}\p{S}]+to[\s\p{P}\p{S}]+accept[\s\p{P}\p{S}]+connections(?:[\s\p{P}\p{S}]+for[\s\p{P}\p{S}]+tunnel[\s\p{P}\p{S}]+(?<id>[a-z0-9][a-z0-9.-]*))?[\s\p{P}\p{S}]*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex ReadyRegex();
 
    internal enum OutputKind
    {
        None,
        Port,
        Ready,
        Connected,
        Disconnected,
        Unrecognized
    }
 
    internal readonly record struct Output(OutputKind Kind, int Port = 0, Uri? Uri = null);
}