// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System;
using System.Diagnostics;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Shell;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Microsoft.TypeAgent.VisualStudio.Bridge;
///
/// WebSocket client that connects to the visualstudio-agent's bridge.
/// Receives BridgeRequest messages, dispatches them through
/// DTEActionExecutor, and sends BridgeResponse messages back.
///
/// Port discovery:
/// The bridge port is no longer hardcoded. On each connect attempt
/// we ask the agent-server's discovery channel where the
/// `(visualStudio, default)` allocation lives. If discovery is
/// unreachable or the agent isn't yet registered, the reconnect
/// loop simply retries — there is no silent fallback to a
/// well-known port. To pin a specific port (e.g. when running the
/// bridge against a manually-launched agent), set
/// `TYPEAGENT_VS_BRIDGE_PORT`; that bypasses discovery entirely.
/// See for the wire protocol and the
/// `AGENT_SERVER_PORT` env-var knob.
///
/// Wire format (matches packages/agents/visualStudio/src/visualStudioActionHandler.ts):
/// request: { id, actionName, parameters }
/// response: { id, success, result?, error? }
///
internal sealed class AgentBridgeClient : IDisposable
{
private const string BridgePortOverrideEnv = "TYPEAGENT_VS_BRIDGE_PORT";
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(3);
private readonly AsyncPackage _package;
private readonly DTEActionExecutor _executor;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private ClientWebSocket? _ws;
public AgentBridgeClient(AsyncPackage package)
{
_package = package;
_executor = new DTEActionExecutor(package);
}
public async Task StartAsync(CancellationToken cancellation)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, cancellation);
while (!linked.IsCancellationRequested)
{
int port = 0;
try
{
// Resolve the port fresh on every attempt: the agent may
// have restarted on a different ephemeral port since the
// last loop iteration, and the standalone shell may have
// come up while we were retrying.
uint? resolved = ResolvePortOverride()
?? await BridgeDiscovery.ResolveBridgePortAsync(linked.Token).ConfigureAwait(false);
if (resolved is null)
{
// Discovery succeeded but the agent isn't registered
// yet — wait one reconnect cycle and try again.
Debug.WriteLine("[TypeAgent] visualStudio agent not yet registered; will retry");
}
else
{
port = (int)resolved.Value;
await ConnectAndReceiveAsync(port, linked.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
Debug.WriteLine($"[TypeAgent] Bridge error (port {port}): {ex.Message}");
}
try
{
await Task.Delay(ReconnectDelay, linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
}
// Returns an explicit port override from `TYPEAGENT_VS_BRIDGE_PORT`,
// or null when the env var is unset/malformed (caller falls through
// to discovery). Mirrors `CODE_WEBSOCKET_HOST` from coda.
private static uint? ResolvePortOverride()
{
string? raw = Environment.GetEnvironmentVariable(BridgePortOverrideEnv);
if (string.IsNullOrEmpty(raw))
{
return null;
}
if (uint.TryParse(raw, out uint p) && p > 0 && p <= 65535)
{
Debug.WriteLine($"[TypeAgent] {BridgePortOverrideEnv} override active: {p}");
return p;
}
Debug.WriteLine($"[TypeAgent] Ignoring malformed {BridgePortOverrideEnv}={raw}");
return null;
}
private async Task ConnectAndReceiveAsync(int port, CancellationToken cancellation)
{
var uri = new Uri($"ws://localhost:{port}");
_ws = new ClientWebSocket();
await _ws.ConnectAsync(uri, cancellation).ConfigureAwait(false);
Debug.WriteLine($"[TypeAgent] Bridge connected to {uri}");
var buffer = new ArraySegment(new byte[16 * 1024]);
var assembly = new StringBuilder();
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
{
assembly.Clear();
WebSocketReceiveResult result;
do
{
result = await _ws.ReceiveAsync(buffer, cancellation).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cancellation).ConfigureAwait(false);
return;
}
assembly.Append(Encoding.UTF8.GetString(buffer.Array!, 0, result.Count));
} while (!result.EndOfMessage);
_ = HandleRequestAsync(assembly.ToString(), cancellation);
}
}
private async Task HandleRequestAsync(string json, CancellationToken cancellation)
{
string id = "";
try
{
var root = JObject.Parse(json);
id = root.Value("id") ?? "";
var actionName = root.Value("actionName") ?? "";
var parameters = root["parameters"] as JObject ?? new JObject();
var result = await _executor.ExecuteAsync(actionName, parameters, cancellation);
await SendResponseAsync(id, success: true, result: result, error: null, cancellation);
}
catch (Exception ex)
{
await SendResponseAsync(id, success: false, result: null, error: ex.Message, cancellation);
}
}
private async Task SendResponseAsync(string id, bool success, object? result, string? error, CancellationToken cancellation)
{
var ws = _ws;
if (ws is null || ws.State != WebSocketState.Open)
{
return;
}
var payload = new BridgeResponse
{
id = id,
success = success,
result = result,
error = error,
};
var json = JsonConvert.SerializeObject(payload, BridgeJson.Settings);
var bytes = Encoding.UTF8.GetBytes(json);
await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, cancellation);
}
public void Dispose()
{
_cts.Cancel();
try { _ws?.Dispose(); } catch { }
}
private sealed class BridgeResponse
{
public string id { get; set; } = "";
public bool success { get; set; }
public object? result { get; set; }
public string? error { get; set; }
}
}
internal static class BridgeJson
{
// Preserve property names as authored (already camelCase) and drop nulls.
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
};
}