using System.Diagnostics; using System.IO; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Text; using System.Text.Json; namespace RevitBridge { /// /// Minimal localhost HTTP bridge. Routes: /// /// GET /ping -> bridge availability /// GET /tools -> tool metadata list /// GET /tools/{name} -> one tool's metadata /// POST /tools/{name}/execute -> execute a tool on the Revit API thread /// /// Every request must carry ?token=... matching the per-start token written /// to %APPDATA%\RevitBridge\bridge.json. The HTTP handling is deliberately /// minimal (loopback only) with byte-accurate UTF-8 body reads. /// internal sealed class BridgeServer : IDisposable { private const int DefaultPort = 47777; private const int MaxPort = 47797; /// Package version stamped into the assembly by scripts/build.ps1. The pi /// extension compares this against its own package version to detect partial /// updates (npm package updated, add-in not redeployed); a build made without /// the stamp reports its default assembly version instead. private static readonly string AddinVersion = typeof(BridgeServer).Assembly.GetCustomAttribute()?.InformationalVersion?.Split('+')[0] ?? typeof(BridgeServer).Assembly.GetName().Version?.ToString(3) ?? "unknown"; private const int MaxRequestBytes = 4_000_000; private const int MaxContentChars = 12_000; private readonly CommandQueue _queue; private readonly ToolRegistry _registry; private readonly string _revitVersion; private readonly Func _hasOpenDocument; /// How often the bridge re-checks that bridge.json still points somewhere /// live (see EnsureBridgeInfo). Cheap: one small file read per tick. private static readonly TimeSpan InfoRefreshInterval = TimeSpan.FromSeconds(30); private TcpListener? _listener; private CancellationTokenSource? _cts; private Timer? _infoTimer; private string _token = string.Empty; private int _port; public BridgeServer(CommandQueue queue, ToolRegistry registry, string revitVersion, Func hasOpenDocument) { _queue = queue; _registry = registry; _revitVersion = revitVersion; _hasOpenDocument = hasOpenDocument; } public void Start() { if (_listener != null) return; _token = Guid.NewGuid().ToString("N"); _cts = new CancellationTokenSource(); for (int port = ResolveStartPort(); port <= MaxPort; port++) { try { _listener = new TcpListener(IPAddress.Loopback, port); _listener.Start(); _port = port; break; } catch { _listener = null; } } if (_listener is null) throw new InvalidOperationException($"Unable to start the Revit bridge on localhost ports {DefaultPort}-{MaxPort}."); WriteBridgeInfo(); _infoTimer = new Timer(_ => EnsureBridgeInfo(), null, InfoRefreshInterval, InfoRefreshInterval); _ = Task.Run(() => AcceptLoopAsync(_cts.Token)); } public void Stop() { // Dispose the refresh timer first: a tick that fired after the delete below // would put the file back and advertise a bridge that is shutting down. try { _infoTimer?.Dispose(); } catch { } _infoTimer = null; try { _cts?.Cancel(); } catch { } try { _listener?.Stop(); } catch { } _listener = null; _cts?.Dispose(); _cts = null; DeleteBridgeInfoIfOwned(); } public void Dispose() => Stop(); private static int ResolveStartPort() { var raw = Environment.GetEnvironmentVariable("REVIT_BRIDGE_PORT"); return int.TryParse(raw, out int port) && port > 0 ? port : DefaultPort; } internal static string BridgeInfoDirectory() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "RevitBridge"); public static string BridgeInfoPath() => Path.Combine(BridgeInfoDirectory(), "bridge.json"); private void WriteBridgeInfo() { Directory.CreateDirectory(BridgeInfoDirectory()); File.WriteAllText(BridgeInfoPath(), JsonSerializer.Serialize(new { baseUrl = $"http://127.0.0.1:{_port}", token = _token, pid = Environment.ProcessId, revitVersion = _revitVersion, startedAtUtc = DateTime.UtcNow.ToString("o"), }, new JsonSerializerOptions { WriteIndented = true })); } /// /// Keeps bridge.json pointing at a bridge that actually answers. Only one file can /// describe "the" bridge, so with two Revits open the newer one owns it (last /// started wins) — but when that owner goes away, this one is still listening and /// would otherwise be undiscoverable forever: closing the newer Revit deletes the /// file, and killing it leaves a stale pid behind that pi cannot reach. /// /// So: rewrite the file when it is missing or names a process that is no longer /// running. A file owned by a LIVE other process is never touched, so two running /// instances cannot flap the file back and forth. /// private void EnsureBridgeInfo() { try { if (_listener is null) return; // stopped between the tick and here string path = BridgeInfoPath(); if (File.Exists(path)) { using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (doc.RootElement.TryGetProperty("pid", out var pidElement) && pidElement.TryGetInt32(out int pid)) { if (pid == Environment.ProcessId || IsProcessAlive(pid)) return; } else { return; // no pid to judge by: leave whatever wrote it alone } } WriteBridgeInfo(); } catch { // A corrupt or briefly locked file is retried on the next tick; this must // never surface as an unhandled exception on a background thread. } } private static bool IsProcessAlive(int pid) { try { using var process = Process.GetProcessById(pid); return !process.HasExited; } catch { // GetProcessById throws when nothing runs under that id. return false; } } private static void DeleteBridgeInfoIfOwned() { try { string path = BridgeInfoPath(); if (!File.Exists(path)) return; using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (doc.RootElement.TryGetProperty("pid", out var pid) && pid.TryGetInt32(out int value) && value == Environment.ProcessId) File.Delete(path); } catch { // Never let cleanup break Revit shutdown. } } private async Task AcceptLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested && _listener != null) { TcpClient? client = null; try { client = await _listener.AcceptTcpClientAsync(token); _ = Task.Run(() => HandleClientAsync(client, token), token); } catch (OperationCanceledException) { client?.Dispose(); break; } catch { client?.Dispose(); } } } private async Task HandleClientAsync(TcpClient client, CancellationToken token) { using var _ = client; NetworkStream? stream = null; try { stream = client.GetStream(); var request = await ReadRequestAsync(stream, token); if (request is null) return; var (method, target, body) = request.Value; var uri = new Uri("http://127.0.0.1" + target); var query = ParseQuery(uri.Query); if (!query.TryGetValue("token", out var tokenValue) || !string.Equals(tokenValue, _token, StringComparison.Ordinal)) { await WriteJsonAsync(stream, 403, new { error = true, message = "Invalid Revit bridge token." }, token); return; } var (status, response) = await RouteAsync(method, uri.AbsolutePath, query, body, token); await WriteJsonAsync(stream, status, response, token); } catch (Exception ex) { if (stream != null) { try { await WriteJsonAsync(stream, 500, new { error = true, message = ex.Message }, token); } catch { } } } } /// Reads one HTTP request with a byte-accurate Content-Length body. private static async Task<(string Method, string Target, string Body)?> ReadRequestAsync(NetworkStream stream, CancellationToken token) { var buffer = new byte[8192]; using var data = new MemoryStream(); int headerEnd = -1; while (headerEnd < 0) { int n = await stream.ReadAsync(buffer, token); if (n <= 0) return null; data.Write(buffer, 0, n); if (data.Length > MaxRequestBytes) throw new InvalidOperationException("Request too large."); headerEnd = FindHeaderEnd(data.GetBuffer(), (int)data.Length); } string headerText = Encoding.ASCII.GetString(data.GetBuffer(), 0, headerEnd); string[] lines = headerText.Split("\r\n"); string[] requestParts = lines[0].Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); if (requestParts.Length < 2) throw new InvalidOperationException("Invalid HTTP request line."); string method = requestParts[0].ToUpperInvariant(); if (method != "GET" && method != "POST") throw new InvalidOperationException("Only GET and POST are supported."); int contentLength = 0; foreach (string line in lines.Skip(1)) { int separator = line.IndexOf(':'); if (separator <= 0) continue; if (line.AsSpan(0, separator).Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) int.TryParse(line.AsSpan(separator + 1).Trim(), out contentLength); } if (contentLength is < 0 or > MaxRequestBytes) throw new InvalidOperationException("Invalid Content-Length."); int bodyStart = headerEnd + 4; var bodyBytes = new byte[contentLength]; int alreadyRead = Math.Min((int)data.Length - bodyStart, contentLength); Array.Copy(data.GetBuffer(), bodyStart, bodyBytes, 0, alreadyRead); int read = alreadyRead; while (read < contentLength) { int n = await stream.ReadAsync(bodyBytes.AsMemory(read, contentLength - read), token); if (n <= 0) break; read += n; } return (method, requestParts[1], Encoding.UTF8.GetString(bodyBytes, 0, read)); } private static int FindHeaderEnd(byte[] data, int length) { for (int i = 3; i < length; i++) { if (data[i - 3] == '\r' && data[i - 2] == '\n' && data[i - 1] == '\r' && data[i] == '\n') return i - 3; } return -1; } private static Dictionary ParseQuery(string query) { var result = new Dictionary(StringComparer.Ordinal); foreach (string pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) { int separator = pair.IndexOf('='); if (separator <= 0) continue; result[WebUtility.UrlDecode(pair[..separator])] = WebUtility.UrlDecode(pair[(separator + 1)..]); } return result; } private async Task<(int Status, object Response)> RouteAsync(string method, string path, Dictionary query, string body, CancellationToken token) { if (method == "GET" && path == "/ping") return (200, new { ok = true, service = "revit-bridge", revitVersion = _revitVersion, pid = Environment.ProcessId, addinVersion = AddinVersion }); if (method == "GET" && path == "/tools") { var tools = _registry.DescribeAll(); return (200, new { protocolVersion = 1, tools, count = tools.Count }); } const string toolPrefix = "/tools/"; if (path.StartsWith(toolPrefix, StringComparison.OrdinalIgnoreCase)) { string tail = path[toolPrefix.Length..].Trim('/'); if (tail.EndsWith("/execute", StringComparison.OrdinalIgnoreCase)) { if (method != "POST") return (405, new { error = true, message = "Tool execution requires POST." }); string name = WebUtility.UrlDecode(tail[..^"/execute".Length].Trim('/')); return await ExecuteToolAsync(name, body, query, token); } if (method != "GET") return (405, new { error = true, message = "Tool description requires GET." }); string describeName = WebUtility.UrlDecode(tail); var tool = _registry.Get(describeName); return tool is null ? (404, new { error = true, message = $"Unknown tool: {describeName}" }) : (200, _registry.Describe(tool)); } return (404, new { error = true, message = $"Unknown Revit bridge endpoint: {path}" }); } private async Task<(int Status, object Response)> ExecuteToolAsync(string name, string body, Dictionary query, CancellationToken token) { var tool = _registry.Get(name); if (tool is null) return (404, new { error = true, message = $"Unknown tool: {name}" }); // Pre-check before enqueueing: with zero documents open Revit does not // pump ExternalEvents, so a queued call would hang instead of failing. // The in-queue NoActiveDocumentException below remains as the backstop // for a document closing between this check and execution. Tools that // never touch the Revit API (RequiresDocument = false) skip the gate. if (tool.RequiresDocument && !_hasOpenDocument()) return (409, new { error = true, hasActiveDocument = false, message = "No active Revit document is open." }); JsonElement args; try { using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); if (doc.RootElement.ValueKind != JsonValueKind.Object) return (400, new { error = true, message = "Tool arguments must be a JSON object." }); args = doc.RootElement.Clone(); } catch (JsonException ex) { return (400, new { error = true, message = $"Invalid JSON tool arguments: {ex.Message}" }); } int timeoutMs = ResolveTimeoutMs(query); try { object? output = tool.RequiresDocument ? await _queue.RunAsync(uiApp => { var document = uiApp.ActiveUIDocument?.Document ?? throw new NoActiveDocumentException(); return tool.Execute(args, new ToolContext(document, uiApp)); }, TimeSpan.FromMilliseconds(timeoutMs)) // RequiresDocument = false tools never touch the Revit API, so they // run right here on the server task instead of the CommandQueue. : tool.Execute(args, new ToolContext(null, null)); return (200, BuildToolResponse(name, output)); } catch (NoActiveDocumentException ex) { return (409, new { error = true, hasActiveDocument = false, message = ex.Message }); } catch (ArgumentException ex) { return (400, new { error = true, toolName = name, message = ex.Message }); } catch (Exception ex) { return (500, new { error = true, toolName = name, message = ex.Message }); } } /// Optional timeout_ms query param, clamped to 1s..10min; default 30s. private static int ResolveTimeoutMs(Dictionary query) => query.TryGetValue("timeout_ms", out var raw) && int.TryParse(raw, out int value) ? Math.Clamp(value, 1_000, 600_000) : 30_000; /// Compact text for model context; full payload in details. Tools may /// return a ToolOutput (CompactText -> content, Payload -> details.payload) or /// any plain object (serialized to both). private static object BuildToolResponse(string toolName, object? output) { object? payload = output; string? compact = null; if (output is ToolOutput toolOutput) { payload = toolOutput.Payload; compact = toolOutput.CompactText; } string text = compact ?? JsonSerializer.Serialize(payload ?? new { }); bool truncated = text.Length > MaxContentChars; if (truncated) { string suffix = $"... [truncated at {MaxContentChars} chars; full payload is in details]"; text = text[..Math.Max(0, MaxContentChars - suffix.Length)] + suffix; } return new { success = true, toolName, content = new[] { new { type = "text", text } }, details = new { payload, contentTruncated = truncated }, isError = false, }; } private static async Task WriteJsonAsync(NetworkStream stream, int status, object value, CancellationToken token) { string reason = status switch { 200 => "OK", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict", 500 => "Internal Server Error", _ => "OK", }; byte[] body = JsonSerializer.SerializeToUtf8Bytes(value); string header = $"HTTP/1.1 {status} {reason}\r\n" + "Content-Type: application/json; charset=utf-8\r\n" + $"Content-Length: {body.Length}\r\n" + "Connection: close\r\n" + "\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(header), token); await stream.WriteAsync(body, token); await stream.FlushAsync(token); } } }