diff --git a/Apollo.Client/Hosting/HostingWorkerProxy.cs b/Apollo.Client/Hosting/HostingWorkerProxy.cs index ef4137f..6bf792a 100644 --- a/Apollo.Client/Hosting/HostingWorkerProxy.cs +++ b/Apollo.Client/Hosting/HostingWorkerProxy.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using Apollo.Components.Console; using Apollo.Components.Hosting; @@ -16,8 +17,10 @@ public class HostingWorkerProxy : IHostingWorker { private readonly SlimWorker _worker; private readonly Dictionary _callbacks = new(); + private readonly ConcurrentDictionary> _pendingRequests = new(); private readonly IJSRuntime _jsRuntime; private readonly WebHostConsoleService _console; + private int _requestCounter; private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -39,7 +42,6 @@ internal async Task InitializeMessageListener() object? data = await e.Data.GetValueAsync(); if (data is string json) { - //_console.AddDebug($"Raw message received: {json}"); var message = JsonSerializer.Deserialize(json); if (message == null) { @@ -106,10 +108,9 @@ internal async Task InitializeMessageListener() } break; case WorkerActions.RouteResponse: - _console.AddInfo($"Response received: {message.Payload}"); + HandleRouteResponse(message.Payload); break; case "": - break; default: @@ -124,6 +125,34 @@ internal async Task InitializeMessageListener() await _worker.AddOnMessageEventListenerAsync(eventListener); } + private void HandleRouteResponse(string payload) + { + try + { + var response = JsonSerializer.Deserialize(payload, SerializerOptions); + if (response == null) + { + _console.AddWarning($"Failed to deserialize route response: {payload}"); + return; + } + + _console.AddInfo($"Response received for {response.RequestId}: {response.StatusCode}"); + + if (_pendingRequests.TryRemove(response.RequestId, out var tcs)) + { + tcs.TrySetResult(response.Body); + } + else + { + _console.AddWarning($"No pending request found for {response.RequestId}"); + } + } + catch (Exception ex) + { + _console.AddError($"Error handling route response: {ex.Message}"); + } + } + public void OnLog(Func callback) { _callbacks[StandardWorkerActions.Log] = callback; @@ -156,9 +185,14 @@ public async Task RunAsync(string code) await _worker.PostMessageAsync(msg.ToSerialized()); } - public async Task SendAsync(HttpMethodType method, string path, string? body = default) + public async Task SendAsync(HttpMethodType method, string path, string? body = default) { - var request = new RouteRequest(method, path, body); + var requestId = $"req_{Interlocked.Increment(ref _requestCounter)}_{DateTimeOffset.UtcNow.Ticks}"; + var tcs = new TaskCompletionSource(); + + _pendingRequests[requestId] = tcs; + + var request = new RouteRequest(method, path, body, requestId); var msg = new WorkerMessage() { Action = WorkerActions.Send, @@ -166,6 +200,17 @@ public async Task SendAsync(HttpMethodType method, string path, string? body = d }; await _worker.PostMessageAsync(msg.ToSerialized()); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + cts.Token.Register(() => + { + if (_pendingRequests.TryRemove(requestId, out var pendingTcs)) + { + pendingTcs.TrySetException(new TimeoutException($"Request {requestId} timed out")); + } + }); + + return await tcs.Task; } public async Task StopAsync() @@ -177,4 +222,4 @@ public async Task StopAsync() }; await _worker.PostMessageAsync(msg.ToSerialized()); } -} \ No newline at end of file +} diff --git a/Apollo.Client/wwwroot/index.html b/Apollo.Client/wwwroot/index.html index e358789..5808807 100644 --- a/Apollo.Client/wwwroot/index.html +++ b/Apollo.Client/wwwroot/index.html @@ -62,6 +62,7 @@ + diff --git a/Apollo.Components/DynamicClient/ClientPreviewTab.razor b/Apollo.Components/DynamicClient/ClientPreviewTab.razor new file mode 100644 index 0000000..38aa7cf --- /dev/null +++ b/Apollo.Components/DynamicClient/ClientPreviewTab.razor @@ -0,0 +1,250 @@ +@inherits Apollo.Components.DynamicTabs.DynamicTabView +@using Apollo.Components.DynamicTabs +@using Apollo.Components.DynamicTabs.Commands +@using Apollo.Components.DynamicClient.Commands +@using Apollo.Components.Hosting +@using Apollo.Components.Hosting.Commands +@using Apollo.Components.Infrastructure.MessageBus +@using Apollo.Components.Shared +@using Apollo.Components.Solutions +@using Apollo.Components.Solutions.Commands +@using Apollo.Components.Theme +@using Apollo.Contracts.Solutions +@using Microsoft.JSInterop +@implements IAsyncDisposable + +
+
+ + +
+ + localhost (virtual) +
+ + + + +
+ +
+ @if (!HostingService.Hosting) + { +
+ + API Server Not Running + + Start your Web API project first, then the client preview will connect automatically. + + + Run Full-Stack + +
+ } + else if (string.IsNullOrEmpty(_documentContent)) + { +
+ + No Client Found + + Add an index.html file to your solution to enable client preview. + +
+ } + else + { + + } +
+ + @if (_requestCount > 0) + { +
+ @_requestCount requests + + View Network Log + +
+ } +
+ +@code { + [Inject] private IDynamicClientService ClientService { get; set; } = default!; + [Inject] private IHostingService HostingService { get; set; } = default!; + [Inject] private SolutionsState State { get; set; } = default!; + [Inject] private IMessageBus Bus { get; set; } = default!; + [Inject] private IJSRuntime JsRuntime { get; set; } = default!; + + private ElementReference _iframeRef; + private DotNetObjectReference? _dotNetRef; + private string? _documentContent; + private bool _isLoading; + private int _requestCount; + private bool _jsInitialized; + + public override string Name { get; set; } = "Client Preview"; + public override Type ComponentType { get; set; } = typeof(ClientPreviewTab); + public override string DefaultArea => DropZones.None; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + HostingService.OnHostingStateChanged += HandleHostingStateChanged; + HostingService.OnRoutesChanged += HandleRoutesChanged; + ClientService.OnRequestLogged += HandleRequestLogged; + State.SolutionFilesChanged += HandleFilesChanged; + + if (HostingService.Hosting && HostingService.Routes?.Count > 0) + { + await LoadClientDocument(); + } + } + + private async Task HandleRoutesChanged() + { + if (HostingService.Routes?.Count > 0) + { + await LoadClientDocument(); + } + await InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetRef = DotNetObjectReference.Create(this); + } + + if (!string.IsNullOrEmpty(_documentContent) && !_jsInitialized) + { + await InitializeIframe(); + } + } + + private async Task InitializeIframe() + { + if (_dotNetRef == null) return; + + try + { + await JsRuntime.InvokeVoidAsync("apolloClientPreview.initialize", _iframeRef, _dotNetRef, _documentContent); + _jsInitialized = true; + } + catch (Exception ex) + { + Console.WriteLine($"Failed to initialize iframe: {ex.Message}"); + } + } + + [JSInvokable] + public async Task HandleApiRequest(int id, string method, string url, string? body) + { + var response = await ClientService.HandleRequestAsync(method, url, body); + _requestCount++; + await InvokeAsync(StateHasChanged); + + return new + { + id, + status = response.StatusCode, + body = response.Body, + headers = response.Headers ?? new Dictionary { { "Content-Type", "application/json" } } + }; + } + + private async Task HandleHostingStateChanged() + { + if (!HostingService.Hosting) + { + _documentContent = null; + _jsInitialized = false; + } + + await InvokeAsync(StateHasChanged); + } + + private void HandleRequestLogged(NetworkRequest request) + { + _requestCount = ClientService.RequestLog.Count; + InvokeAsync(StateHasChanged); + } + + private void HandleFilesChanged() + { + if (HostingService.Hosting && State.Project != null) + { + _ = RefreshPreview(); + } + } + + private async Task LoadClientDocument() + { + if (State.Project == null) return; + + _documentContent = ClientService.BuildClientDocument(State.Project); + _jsInitialized = false; + await InvokeAsync(StateHasChanged); + } + + private async Task RefreshPreview() + { + _isLoading = true; + StateHasChanged(); + + try + { + _jsInitialized = false; + await LoadClientDocument(); + + if (!string.IsNullOrEmpty(_documentContent)) + { + await Task.Delay(50); + await InitializeIframe(); + } + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private async Task StartApiAndClient() + { + if (State.Project == null) return; + + await Bus.PublishAsync(new StartRunning()); + await ClientService.StartAsync(State.Project); + } + + public async ValueTask DisposeAsync() + { + HostingService.OnHostingStateChanged -= HandleHostingStateChanged; + HostingService.OnRoutesChanged -= HandleRoutesChanged; + ClientService.OnRequestLogged -= HandleRequestLogged; + State.SolutionFilesChanged -= HandleFilesChanged; + + _dotNetRef?.Dispose(); + + try + { + await JsRuntime.InvokeVoidAsync("apolloClientPreview.dispose"); + } + catch + { + } + } +} \ No newline at end of file diff --git a/Apollo.Components/DynamicClient/Commands/RefreshClient.cs b/Apollo.Components/DynamicClient/Commands/RefreshClient.cs new file mode 100644 index 0000000..8520190 --- /dev/null +++ b/Apollo.Components/DynamicClient/Commands/RefreshClient.cs @@ -0,0 +1,3 @@ +namespace Apollo.Components.DynamicClient.Commands; + +public record RefreshClient; diff --git a/Apollo.Components/DynamicClient/Commands/StartClient.cs b/Apollo.Components/DynamicClient/Commands/StartClient.cs new file mode 100644 index 0000000..8b898cf --- /dev/null +++ b/Apollo.Components/DynamicClient/Commands/StartClient.cs @@ -0,0 +1,5 @@ +using Apollo.Components.Solutions; + +namespace Apollo.Components.DynamicClient.Commands; + +public record StartClient(SolutionModel Solution, string? EntryFile = null); diff --git a/Apollo.Components/DynamicClient/Commands/StopClient.cs b/Apollo.Components/DynamicClient/Commands/StopClient.cs new file mode 100644 index 0000000..deb92ab --- /dev/null +++ b/Apollo.Components/DynamicClient/Commands/StopClient.cs @@ -0,0 +1,3 @@ +namespace Apollo.Components.DynamicClient.Commands; + +public record StopClient; diff --git a/Apollo.Components/DynamicClient/Consumers/WebHostClientPreviewer.cs b/Apollo.Components/DynamicClient/Consumers/WebHostClientPreviewer.cs new file mode 100644 index 0000000..2de4f88 --- /dev/null +++ b/Apollo.Components/DynamicClient/Consumers/WebHostClientPreviewer.cs @@ -0,0 +1,29 @@ +using Apollo.Components.DynamicTabs; +using Apollo.Components.DynamicTabs.Commands; +using Apollo.Components.Hosting.Events; +using Apollo.Components.Infrastructure.MessageBus; +using Apollo.Components.Solutions; + +namespace Apollo.Components.DynamicClient.Consumers; + +public class WebHostClientPreviewer : IConsumer +{ + private readonly IMessageBus _bus; + private readonly SolutionsState _state; + + public WebHostClientPreviewer(IMessageBus bus, SolutionsState state) + { + _bus = bus; + _state = state; + } + + public async Task Consume(WebHostReady message) + { + await Task.Delay(1000); + await Task.Yield(); + if (_state?.Project?.Files.Any(x => x.IsHtml() || x.IsRazor()) ?? false) + { + await _bus.PublishAsync(new UpdateTabLocationByName("Client Preview", DropZones.Floating)); + } + } +} \ No newline at end of file diff --git a/Apollo.Components/DynamicClient/DynamicClientService.cs b/Apollo.Components/DynamicClient/DynamicClientService.cs new file mode 100644 index 0000000..91ec656 --- /dev/null +++ b/Apollo.Components/DynamicClient/DynamicClientService.cs @@ -0,0 +1,268 @@ +using System.Diagnostics; +using Apollo.Components.Hosting; +using Apollo.Components.Solutions; +using Apollo.Contracts.Hosting; + +namespace Apollo.Components.DynamicClient; + +public class DynamicClientService : IDynamicClientService +{ + private readonly IHostingService _hostingService; + private readonly List _requestLog = new(); + private int _requestCounter; + + public bool IsRunning { get; private set; } + public IReadOnlyList RequestLog => _requestLog.AsReadOnly(); + + public event Func? OnStateChanged; + public event Action? OnRequestLogged; + + public DynamicClientService(IHostingService hostingService) + { + _hostingService = hostingService; + } + + public async Task StartAsync(SolutionModel solution, string? entryFile = null) + { + if (!_hostingService.Hosting) + { + await _hostingService.RunAsync(solution); + } + + IsRunning = true; + _requestCounter = 0; + _requestLog.Clear(); + + if (OnStateChanged != null) + await OnStateChanged.Invoke(); + } + + public async Task StopAsync() + { + IsRunning = false; + + if (OnStateChanged != null) + await OnStateChanged.Invoke(); + } + + public async Task HandleRequestAsync(string method, string path, string? body) + { + var request = new NetworkRequest + { + Id = ++_requestCounter, + Timestamp = DateTime.UtcNow, + Method = method.ToUpperInvariant(), + Path = path, + RequestBody = body + }; + + _requestLog.Add(request); + OnRequestLogged?.Invoke(request); + + var stopwatch = Stopwatch.StartNew(); + + try + { + if (!_hostingService.Hosting) + { + request.StatusCode = 503; + request.ResponseBody = "API server not running"; + request.IsComplete = true; + request.DurationMs = stopwatch.Elapsed.TotalMilliseconds; + return new NetworkResponse(503, "API server not running"); + } + + var methodType = ParseHttpMethod(method); + var response = await _hostingService.SendAsync(methodType, path, body); + + stopwatch.Stop(); + + request.ResponseBody = response; + request.StatusCode = 200; + request.DurationMs = stopwatch.Elapsed.TotalMilliseconds; + request.IsComplete = true; + + return new NetworkResponse(200, response); + } + catch (Exception ex) + { + stopwatch.Stop(); + request.StatusCode = 500; + request.ResponseBody = ex.Message; + request.DurationMs = stopwatch.Elapsed.TotalMilliseconds; + request.IsComplete = true; + + return new NetworkResponse(500, ex.Message); + } + } + + public string BuildClientDocument(SolutionModel solution, string? entryFile = null) + { + var htmlFile = entryFile != null + ? solution.Files.FirstOrDefault(f => f.Name.Equals(entryFile, StringComparison.OrdinalIgnoreCase)) + : solution.Files.FirstOrDefault(f => + f.Name.Equals("index.html", StringComparison.OrdinalIgnoreCase) || + f.Uri.Contains("/client/", StringComparison.OrdinalIgnoreCase) && f.Name.EndsWith(".html", StringComparison.OrdinalIgnoreCase)); + + if (htmlFile == null) + { + return BuildDefaultErrorDocument("No HTML file found. Create an index.html in your solution."); + } + + var html = htmlFile.Data; + + var jsFiles = solution.Files + .Where(f => f.Name.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) + .Where(f => !f.Name.Equals("virtual-fetch.js", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var cssFiles = solution.Files + .Where(f => f.Name.EndsWith(".css", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + html = InjectVirtualFetch(html); + html = InlineAssets(html, jsFiles, cssFiles); + + return html; + } + + public void ClearRequestLog() + { + _requestLog.Clear(); + } + + private string InjectVirtualFetch(string html) + { + const string virtualFetchScript = """ + + """; + + var headCloseIndex = html.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (headCloseIndex > 0) + { + return html.Insert(headCloseIndex, virtualFetchScript); + } + + return virtualFetchScript + html; + } + + private string InlineAssets(string html, List jsFiles, List cssFiles) + { + foreach (var cssFile in cssFiles) + { + var linkPattern = $"]*href=[\"']{cssFile.Name}[\"'][^>]*>"; + var styleTag = $""; + html = System.Text.RegularExpressions.Regex.Replace(html, linkPattern, styleTag, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + foreach (var jsFile in jsFiles) + { + var scriptPattern = $"]*src=[\"']{jsFile.Name}[\"'][^>]*>"; + var inlineScript = $""; + html = System.Text.RegularExpressions.Regex.Replace(html, scriptPattern, inlineScript, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + return html; + } + + private string BuildDefaultErrorDocument(string message) + { + return $$""" + + + + + + +
+

⚠️ Client Preview Error

+

{{message}}

+
+ + + """; + } + + private static HttpMethodType ParseHttpMethod(string method) => method.ToUpperInvariant() switch + { + "GET" => HttpMethodType.Get, + "POST" => HttpMethodType.Post, + "PUT" => HttpMethodType.Put, + "DELETE" => HttpMethodType.Delete, + "PATCH" => HttpMethodType.Patch, + _ => HttpMethodType.Get + }; +} + diff --git a/Apollo.Components/DynamicClient/IDynamicClientService.cs b/Apollo.Components/DynamicClient/IDynamicClientService.cs new file mode 100644 index 0000000..7c40714 --- /dev/null +++ b/Apollo.Components/DynamicClient/IDynamicClientService.cs @@ -0,0 +1,36 @@ +using Apollo.Components.Solutions; +using Apollo.Contracts.Hosting; + +namespace Apollo.Components.DynamicClient; + +public interface IDynamicClientService +{ + bool IsRunning { get; } + IReadOnlyList RequestLog { get; } + + event Func? OnStateChanged; + event Action? OnRequestLogged; + + Task StartAsync(SolutionModel solution, string? entryFile = null); + Task StopAsync(); + Task HandleRequestAsync(string method, string path, string? body); + + string BuildClientDocument(SolutionModel solution, string? entryFile = null); + void ClearRequestLog(); +} + +public record NetworkRequest +{ + public int Id { get; init; } + public DateTime Timestamp { get; init; } + public string Method { get; init; } = "GET"; + public string Path { get; init; } = "/"; + public string? RequestBody { get; init; } + public string? ResponseBody { get; set; } + public int StatusCode { get; set; } = 200; + public double DurationMs { get; set; } + public bool IsComplete { get; set; } +} + +public record NetworkResponse(int StatusCode, string Body, Dictionary? Headers = null); + diff --git a/Apollo.Components/DynamicClient/NetworkInspectorTab.razor b/Apollo.Components/DynamicClient/NetworkInspectorTab.razor new file mode 100644 index 0000000..05488d1 --- /dev/null +++ b/Apollo.Components/DynamicClient/NetworkInspectorTab.razor @@ -0,0 +1,219 @@ +@inherits Apollo.Components.DynamicTabs.DynamicTabView +@using Apollo.Components.DynamicTabs +@using Apollo.Components.Shared +@using Apollo.Components.Theme +@using Apollo.Components.Hosting +@implements IDisposable + +
+
+ + + + + + @ClientService.RequestLog.Count requests + +
+ +
+ @if (!ClientService.RequestLog.Any()) + { +
+ + No network requests yet + + Requests from the client preview will appear here + +
+ } + else + { + + + + Status + Method + Path + Time + + + + @foreach (var request in ClientService.RequestLog.OrderByDescending(r => r.Timestamp)) + { + + + + @request.StatusCode + + + + + + + @request.Path + + + @if (request.IsComplete) + { + @request.DurationMs.ToString("F0")ms + } + else + { + + } + + + } + + + } +
+ + @if (_selectedRequest != null) + { +
+ + +
+ @_selectedRequest.Method @_selectedRequest.Path + + @_selectedRequest.Timestamp.ToString("HH:mm:ss.fff") + + @if (!string.IsNullOrEmpty(_selectedRequest.RequestBody)) + { + Body +
@FormatJson(_selectedRequest.RequestBody)
+ } +
+
+ +
+ + Status: @_selectedRequest.StatusCode + + (@_selectedRequest.DurationMs.ToString("F0")ms) + + + @if (!string.IsNullOrEmpty(_selectedRequest.ResponseBody)) + { + Body +
@FormatJson(_selectedRequest.ResponseBody)
+ } +
+
+
+
+ } +
+ + + +@code { + [Inject] private IDynamicClientService ClientService { get; set; } = default!; + + private NetworkRequest? _selectedRequest; + + public override string Name { get; set; } = "Network"; + public override Type ComponentType { get; set; } = typeof(NetworkInspectorTab); + public override string DefaultArea => DropZones.None; + + protected override void OnInitialized() + { + base.OnInitialized(); + ClientService.OnRequestLogged += HandleRequestLogged; + } + + public void Dispose() + { + ClientService.OnRequestLogged -= HandleRequestLogged; + } + + private void HandleRequestLogged(NetworkRequest request) + { + InvokeAsync(StateHasChanged); + } + + private void SelectRequest(NetworkRequest request) + { + _selectedRequest = _selectedRequest == request ? null : request; + } + + private void ClearLog() + { + ClientService.ClearRequestLog(); + _selectedRequest = null; + StateHasChanged(); + } + + private Color GetStatusColor(int statusCode) => statusCode switch + { + >= 200 and < 300 => Color.Success, + >= 400 and < 500 => Color.Warning, + >= 500 => Color.Error, + _ => Color.Default + }; + + private Apollo.Contracts.Hosting.HttpMethodType ParseMethod(string method) => method.ToUpperInvariant() switch + { + "GET" => Apollo.Contracts.Hosting.HttpMethodType.Get, + "POST" => Apollo.Contracts.Hosting.HttpMethodType.Post, + "PUT" => Apollo.Contracts.Hosting.HttpMethodType.Put, + "DELETE" => Apollo.Contracts.Hosting.HttpMethodType.Delete, + "PATCH" => Apollo.Contracts.Hosting.HttpMethodType.Patch, + _ => Apollo.Contracts.Hosting.HttpMethodType.Get + }; + + private string FormatJson(string json) + { + try + { + var parsed = System.Text.Json.JsonSerializer.Deserialize(json); + return System.Text.Json.JsonSerializer.Serialize(parsed, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }); + } + catch + { + return json; + } + } +} + diff --git a/Apollo.Components/DynamicTabs/Commands/UpdateTabLocation.cs b/Apollo.Components/DynamicTabs/Commands/UpdateTabLocation.cs index 3418672..4bf0d0c 100644 --- a/Apollo.Components/DynamicTabs/Commands/UpdateTabLocation.cs +++ b/Apollo.Components/DynamicTabs/Commands/UpdateTabLocation.cs @@ -1,3 +1,5 @@ namespace Apollo.Components.DynamicTabs.Commands; -public record UpdateTabLocation(Guid TabId, string Location); \ No newline at end of file +public record UpdateTabLocation(Guid TabId, string Location); + +public record UpdateTabLocationByName(string Name, string Location); \ No newline at end of file diff --git a/Apollo.Components/DynamicTabs/Consumers/TabLocationUpdater.cs b/Apollo.Components/DynamicTabs/Consumers/TabLocationUpdater.cs index a43d178..fdca264 100644 --- a/Apollo.Components/DynamicTabs/Consumers/TabLocationUpdater.cs +++ b/Apollo.Components/DynamicTabs/Consumers/TabLocationUpdater.cs @@ -4,7 +4,7 @@ namespace Apollo.Components.DynamicTabs.Consumers; -public class TabLocationUpdater : IConsumer +public class TabLocationUpdater : IConsumer, IConsumer { private readonly TabViewState _state; @@ -22,4 +22,14 @@ public Task Consume(UpdateTabLocation message) _state.UpdateTabLocation(tab, message.Location); return Task.CompletedTask; } + + public Task Consume(UpdateTabLocationByName message) + { + var tab = _state.Tabs.FirstOrDefault(x => x.Name.Equals(message.Name, StringComparison.OrdinalIgnoreCase)); + + if (tab == null) return Task.CompletedTask; + + _state.UpdateTabLocation(tab, message.Location); + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/Apollo.Components/Editor/TabViewState.cs b/Apollo.Components/Editor/TabViewState.cs index 892b89b..a6960ea 100644 --- a/Apollo.Components/Editor/TabViewState.cs +++ b/Apollo.Components/Editor/TabViewState.cs @@ -9,6 +9,7 @@ using Apollo.Components.Solutions; using Apollo.Components.Terminal; using Apollo.Components.Testing; +using Apollo.Components.DynamicClient; namespace Apollo.Components.Editor; @@ -213,6 +214,16 @@ private List InitializeDefaultLayout() { AreaIdentifier = DropZones.Right }, + new ClientPreviewTab() + { + AreaIdentifier = DropZones.None, + IsActive = false + }, + new NetworkInspectorTab() + { + AreaIdentifier = DropZones.None, + IsActive = false + }, new TerminalTab() { AreaIdentifier = DropZones.Docked @@ -229,7 +240,7 @@ private List InitializeDefaultLayout() defaultTabs.Add(new SystemLogViewer()); defaultTabs.Add(new PreviewTab() { - AreaIdentifier = DropZones.Right, + AreaIdentifier = DropZones.None, IsActive = false }); } diff --git a/Apollo.Components/Hosting/Consumers/WebApiProjectBuilder.cs b/Apollo.Components/Hosting/Consumers/WebApiProjectBuilder.cs index 59e868f..5b34cd5 100644 --- a/Apollo.Components/Hosting/Consumers/WebApiProjectBuilder.cs +++ b/Apollo.Components/Hosting/Consumers/WebApiProjectBuilder.cs @@ -1,3 +1,4 @@ +using Apollo.Components.DynamicTabs; using Apollo.Components.DynamicTabs.Commands; using Apollo.Components.Infrastructure.MessageBus; using Apollo.Components.Solutions; @@ -27,6 +28,7 @@ public async Task Consume(BuildSolution message) return; await _bus.PublishAsync(new FocusTab("Web Host")); + await _hostingService.RunAsync(solution); } } \ No newline at end of file diff --git a/Apollo.Components/Hosting/Events/HostWorkerSolutionRunRequested.cs b/Apollo.Components/Hosting/Events/HostWorkerSolutionRunRequested.cs new file mode 100644 index 0000000..bf8631d --- /dev/null +++ b/Apollo.Components/Hosting/Events/HostWorkerSolutionRunRequested.cs @@ -0,0 +1,5 @@ +using Apollo.Contracts.Solutions; + +namespace Apollo.Components.Hosting.Events; + +public record HostWorkerSolutionRunRequested(Solution Solution); \ No newline at end of file diff --git a/Apollo.Components/Hosting/Events/WebHostReady.cs b/Apollo.Components/Hosting/Events/WebHostReady.cs new file mode 100644 index 0000000..4344186 --- /dev/null +++ b/Apollo.Components/Hosting/Events/WebHostReady.cs @@ -0,0 +1,3 @@ +namespace Apollo.Components.Hosting.Events; + +public record WebHostReady(); \ No newline at end of file diff --git a/Apollo.Components/Hosting/IHostingService.cs b/Apollo.Components/Hosting/IHostingService.cs index 45af45b..19b5332 100644 --- a/Apollo.Components/Hosting/IHostingService.cs +++ b/Apollo.Components/Hosting/IHostingService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Apollo.Components.Console; using Apollo.Components.DynamicTabs.Commands; +using Apollo.Components.Hosting.Events; using Apollo.Components.Infrastructure.MessageBus; using Apollo.Components.NuGet; using Apollo.Components.Solutions; @@ -92,6 +93,8 @@ public async Task RunAsync(SolutionModel solution) contract.NuGetReferences = await LoadNuGetReferencesAsync(); await _hostingWorker.RunAsync(JsonSerializer.Serialize(contract)); + + await _bus.PublishAsync(new HostWorkerSolutionRunRequested(contract)); } private async Task> LoadNuGetReferencesAsync() @@ -181,6 +184,8 @@ protected async Task HandleRoutesDiscovered(IReadOnlyList routes) _console.AddInfo($"Discovered {routes.Count} routes"); _routes = routes.Select(x => x.ToViewModel()).ToList(); await NotifyRoutesChangedAsync(); + + await _bus.PublishAsync(new WebHostReady()); } public async Task SendAsync(HttpMethodType method, string path, string? content = default) @@ -195,9 +200,9 @@ public async Task SendAsync(HttpMethodType method, string path, string? try { - await _hostingWorker.SendAsync(method, path, content); - _console.AddSuccess($"Request sent to {path}"); - return "Request sent successfully"; + var response = await _hostingWorker.SendAsync(method, path, content); + _console.AddSuccess($"Response from {path}: {response.Length} chars"); + return response; } catch (Exception ex) { diff --git a/Apollo.Components/Hosting/IHostingWorker.cs b/Apollo.Components/Hosting/IHostingWorker.cs index 1cbfcd1..c8a33fd 100644 --- a/Apollo.Components/Hosting/IHostingWorker.cs +++ b/Apollo.Components/Hosting/IHostingWorker.cs @@ -7,7 +7,7 @@ public interface IHostingWorker : IWorkerProxy { public Task RunAsync(string code); - public Task SendAsync(HttpMethodType method, string path, string body = default); + public Task SendAsync(HttpMethodType method, string path, string? body = default); public Task StopAsync(); diff --git a/Apollo.Components/Infrastructure/RegistrationExtensions.cs b/Apollo.Components/Infrastructure/RegistrationExtensions.cs index 7113c6d..600a992 100644 --- a/Apollo.Components/Infrastructure/RegistrationExtensions.cs +++ b/Apollo.Components/Infrastructure/RegistrationExtensions.cs @@ -16,6 +16,7 @@ using Apollo.Components.Terminal.CommandServices; using Apollo.Components.Testing; using Apollo.Components.Theme; +using Apollo.Components.DynamicClient; using Blazored.LocalStorage; using KristofferStrube.Blazor.FileSystemAccess; using Microsoft.Extensions.DependencyInjection; @@ -47,6 +48,7 @@ public static IServiceCollection AddComponentsAndServices(this IServiceCollectio services.AddFluentUIComponents(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Apollo.Components/Library/LibraryState.cs b/Apollo.Components/Library/LibraryState.cs index 8bc97a7..44e853c 100644 --- a/Apollo.Components/Library/LibraryState.cs +++ b/Apollo.Components/Library/LibraryState.cs @@ -17,6 +17,7 @@ public class LibraryState public List Projects { get; set; } = [ + FullStackProject.Create(), FizzBuzzProject.Create(), MinimalApiProject.Create(), UntitledProject.Create(), diff --git a/Apollo.Components/Library/SampleProjects/FullStackProject.cs b/Apollo.Components/Library/SampleProjects/FullStackProject.cs new file mode 100644 index 0000000..a8aa5dc --- /dev/null +++ b/Apollo.Components/Library/SampleProjects/FullStackProject.cs @@ -0,0 +1,494 @@ +using Apollo.Components.Solutions; +using Apollo.Contracts.Solutions; + +namespace Apollo.Components.Library.SampleProjects; + +public static class FullStackProject +{ + public static SolutionModel Create() + { + var solution = new SolutionModel + { + Name = "FullStackDemo", + Description = "Full-stack demo with Minimal API backend and HTML/JS frontend", + ProjectType = ProjectType.WebApi, + Items = new List() + }; + + var rootFolder = new Folder + { + Name = "FullStackDemo", + Uri = "virtual/FullStackDemo", + Items = new List() + }; + solution.Items.Add(rootFolder); + + solution.Items.Add(new SolutionFile + { + Name = "Program.cs", + Uri = "virtual/FullStackDemo/Program.cs", + Data = ApiCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "index.html", + Uri = "virtual/FullStackDemo/index.html", + Data = HtmlCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "app.js", + Uri = "virtual/FullStackDemo/app.js", + Data = JavaScriptCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + solution.Items.Add(new SolutionFile + { + Name = "styles.css", + Uri = "virtual/FullStackDemo/styles.css", + Data = CssCode, + CreatedAt = DateTimeOffset.Now, + ModifiedAt = DateTimeOffset.Now + }); + + return solution; + } + + private const string ApiCode = """ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var todos = new List +{ + new Todo { Id = 1, Title = "Learn Apollo Editor", Completed = false }, + new Todo { Id = 2, Title = "Build a full-stack app", Completed = false }, + new Todo { Id = 3, Title = "Share with friends", Completed = false } +}; +var nextId = 4; + +app.MapGet("/api/todos", () => System.Text.Json.JsonSerializer.Serialize(todos)); + +app.MapPost("/api/todos", (string body) => +{ + var input = System.Text.Json.JsonSerializer.Deserialize(body); + var todo = new Todo { Id = nextId++, Title = input.title, Completed = false }; + todos.Add(todo); + return System.Text.Json.JsonSerializer.Serialize(todo); +}); + +app.MapPut("/api/todos/{id}", (string id, string body) => +{ + var todoId = int.Parse(id); + var input = System.Text.Json.JsonSerializer.Deserialize(body); + var todo = todos.FirstOrDefault(t => t.Id == todoId); + if (todo != null) + { + if (input.title != null) todo.Title = input.title; + if (input.completed.HasValue) todo.Completed = input.completed.Value; + } + return System.Text.Json.JsonSerializer.Serialize(todo); +}); + +app.MapDelete("/api/todos/{id}", (string id) => +{ + var todoId = int.Parse(id); + todos.RemoveAll(t => t.Id == todoId); + return "{}"; +}); + +app.Run(); + +class Todo { public int Id { get; set; } public string Title { get; set; } public bool Completed { get; set; } } +class TodoInput { public string title { get; set; } public bool? completed { get; set; } } +"""; + + private const string HtmlCode = """ + + + + + + Todo App - Apollo Full-Stack Demo + + + +
+
+

📝 Todo App

+

Full-Stack Demo powered by Apollo

+
+ +
+ + +
+ +
+ + + +
+ +
    + +
    + 0 items left + +
    +
    + + + + +"""; + + private const string JavaScriptCode = """ +let todos = []; +let currentFilter = 'all'; + +async function loadTodos() { + try { + const response = await fetch('/api/todos'); + todos = await response.json(); + renderTodos(); + } catch (error) { + console.error('Failed to load todos:', error); + } +} + +async function addTodo() { + const input = document.getElementById('new-todo-input'); + const title = input.value.trim(); + + if (!title) return; + + try { + const response = await fetch('/api/todos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }) + }); + + if (response.ok) { + const newTodo = await response.json(); + todos.push(newTodo); + input.value = ''; + renderTodos(); + } + } catch (error) { + console.error('Failed to add todo:', error); + } +} + +async function toggleTodo(id) { + const todo = todos.find(t => t.Id === id); + if (!todo) return; + + try { + const response = await fetch(`/api/todos/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ completed: !todo.Completed }) + }); + + if (response.ok) { + const updated = await response.json(); + const index = todos.findIndex(t => t.Id === id); + todos[index] = updated; + renderTodos(); + } + } catch (error) { + console.error('Failed to toggle todo:', error); + } +} + +async function deleteTodo(id) { + try { + const response = await fetch(`/api/todos/${id}`, { + method: 'DELETE' + }); + + if (response.ok) { + todos = todos.filter(t => t.Id !== id); + renderTodos(); + } + } catch (error) { + console.error('Failed to delete todo:', error); + } +} + +async function clearCompleted() { + const completed = todos.filter(t => t.Completed); + for (const todo of completed) { + await deleteTodo(todo.Id); + } +} + +function setFilter(filter) { + currentFilter = filter; + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + renderTodos(); +} + +function getFilteredTodos() { + switch (currentFilter) { + case 'active': + return todos.filter(t => !t.Completed); + case 'completed': + return todos.filter(t => t.Completed); + default: + return todos; + } +} + +function renderTodos() { + const list = document.getElementById('todo-list'); + const filtered = getFilteredTodos(); + + list.innerHTML = filtered.map(todo => ` +
  • + + ${escapeHtml(todo.Title)} + +
  • + `).join(''); + + const activeCount = todos.filter(t => !t.Completed).length; + document.getElementById('items-left').textContent = + `${activeCount} item${activeCount !== 1 ? 's' : ''} left`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Initialize filter buttons +document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => setFilter(btn.dataset.filter)); +}); + +// Handle Enter key in input +document.getElementById('new-todo-input').addEventListener('keypress', (e) => { + if (e.key === 'Enter') addTodo(); +}); + +// Load todos on page load +loadTodos(); +"""; + + private const string CssCode = """ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + min-height: 100vh; + display: flex; + justify-content: center; + padding: 2rem; + color: #eee; +} + +.container { + width: 100%; + max-width: 500px; + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 2rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +header { + text-align: center; + margin-bottom: 2rem; +} + +header h1 { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.subtitle { + color: #888; + font-size: 0.875rem; +} + +.add-todo { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.add-todo input { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + font-size: 1rem; +} + +.add-todo input::placeholder { + color: #666; +} + +.add-todo input:focus { + outline: none; + border-color: #6366f1; +} + +.add-todo button, +#clear-completed { + padding: 0.75rem 1.5rem; + background: #6366f1; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + transition: background 0.2s; +} + +.add-todo button:hover, +#clear-completed:hover { + background: #4f46e5; +} + +.filters { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.filter-btn { + flex: 1; + padding: 0.5rem; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #888; + cursor: pointer; + transition: all 0.2s; +} + +.filter-btn:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.2); +} + +.filter-btn.active { + background: rgba(99, 102, 241, 0.2); + border-color: #6366f1; + color: #6366f1; +} + +#todo-list { + list-style: none; + margin-bottom: 1rem; +} + +.todo-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + margin-bottom: 0.5rem; + transition: background 0.2s; +} + +.todo-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.todo-item.completed .todo-text { + text-decoration: line-through; + color: #666; +} + +.todo-item input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: #6366f1; +} + +.todo-text { + flex: 1; +} + +.delete-btn { + width: 28px; + height: 28px; + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 6px; + color: #ef4444; + cursor: pointer; + opacity: 0; + transition: all 0.2s; + font-size: 1.25rem; + line-height: 1; +} + +.todo-item:hover .delete-btn { + opacity: 1; +} + +.delete-btn:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; +} + +footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +#items-left { + color: #666; + font-size: 0.875rem; +} + +#clear-completed { + padding: 0.5rem 1rem; + font-size: 0.875rem; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + color: #888; +} + +#clear-completed:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + color: #ef4444; +} +"""; +} + diff --git a/Apollo.Components/Solutions/ISolutionItem.cs b/Apollo.Components/Solutions/ISolutionItem.cs index 3f8ada1..004d41c 100644 --- a/Apollo.Components/Solutions/ISolutionItem.cs +++ b/Apollo.Components/Solutions/ISolutionItem.cs @@ -1,3 +1,5 @@ +using Apollo.Contracts.Solutions; + namespace Apollo.Components.Solutions; public interface ISolutionItem @@ -8,4 +10,16 @@ public interface ISolutionItem DateTimeOffset ModifiedAt { get; set; } ISolutionItem Clone(); +} + +public static class SolutionItemExtensions +{ + public static bool IsCSharp(this SolutionItem file) + => file.Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); + + public static bool IsHtml(this SolutionItem file) + => file.Path.EndsWith(".html", StringComparison.OrdinalIgnoreCase); + + public static bool IsRazor(this SolutionItem file) + => file.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase); } \ No newline at end of file diff --git a/Apollo.Components/Solutions/SolutionFileExtensions.cs b/Apollo.Components/Solutions/SolutionFileExtensions.cs index 8a7a11b..39761d0 100644 --- a/Apollo.Components/Solutions/SolutionFileExtensions.cs +++ b/Apollo.Components/Solutions/SolutionFileExtensions.cs @@ -8,6 +8,9 @@ public static bool IsCSharp(this SolutionFile file) public static bool IsHtml(this SolutionFile file) => file.Extension.Equals(".html", StringComparison.OrdinalIgnoreCase); + public static bool IsRazor(this SolutionFile file) + => file.Extension.Equals(".razor", StringComparison.OrdinalIgnoreCase); + public static string GetMonacoLanguage(this SolutionFile file) => file.Extension.ToLowerInvariant() switch { diff --git a/Apollo.Components/Solutions/SolutionsState.cs b/Apollo.Components/Solutions/SolutionsState.cs index a03041d..7d4087b 100644 --- a/Apollo.Components/Solutions/SolutionsState.cs +++ b/Apollo.Components/Solutions/SolutionsState.cs @@ -33,6 +33,7 @@ public SolutionsState( var fizzBuzzProject = FizzBuzzProject.Create(); var minimalApiProject = MinimalApiProject.Create(); var simpleLibraryProject = SimpleLibraryProject.Create(); + var fullStackProject = FullStackProject.Create(); Project = untitledProject; Solutions = @@ -40,7 +41,8 @@ public SolutionsState( untitledProject, fizzBuzzProject, minimalApiProject, - simpleLibraryProject + simpleLibraryProject, + fullStackProject ]; ActiveFile = Project.Files.FirstOrDefault(); diff --git a/Apollo.Components/wwwroot/client-preview.js b/Apollo.Components/wwwroot/client-preview.js new file mode 100644 index 0000000..7a2939d --- /dev/null +++ b/Apollo.Components/wwwroot/client-preview.js @@ -0,0 +1,77 @@ +// Apollo Client Preview - iframe communication bridge + +window.apolloClientPreview = { + iframe: null, + dotNetRef: null, + messageHandler: null, + + initialize: function(iframeElement, dotNetReference, htmlContent) { + this.iframe = iframeElement; + this.dotNetRef = dotNetReference; + + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + } + + this.messageHandler = async (event) => { + if (event.source !== this.iframe?.contentWindow) { + return; + } + + const data = event.data; + if (data && data.type === 'apollo_request') { + try { + const response = await this.dotNetRef.invokeMethodAsync( + 'HandleApiRequest', + data.id, + data.method, + data.url, + data.body + ); + + this.iframe.contentWindow.postMessage({ + type: 'apollo_response', + id: response.id, + status: response.status, + body: response.body, + headers: response.headers + }, '*'); + } catch (error) { + console.error('[Apollo] Error handling API request:', error); + + this.iframe.contentWindow.postMessage({ + type: 'apollo_response', + id: data.id, + status: 500, + body: JSON.stringify({ error: error.message }), + headers: { 'Content-Type': 'application/json' } + }, '*'); + } + } + }; + + window.addEventListener('message', this.messageHandler); + + // Write content to iframe using srcdoc + this.iframe.srcdoc = htmlContent; + + console.log('[Apollo] Client preview initialized'); + }, + + refresh: function(htmlContent) { + if (this.iframe) { + this.iframe.srcdoc = htmlContent; + } + }, + + dispose: function() { + if (this.messageHandler) { + window.removeEventListener('message', this.messageHandler); + this.messageHandler = null; + } + this.iframe = null; + this.dotNetRef = null; + console.log('[Apollo] Client preview disposed'); + } +}; + diff --git a/Apollo.Hosting.Worker/Program.cs b/Apollo.Hosting.Worker/Program.cs index d87f960..09370a6 100644 --- a/Apollo.Hosting.Worker/Program.cs +++ b/Apollo.Hosting.Worker/Program.cs @@ -122,6 +122,9 @@ async Task HandleRunMessage(WorkerMessage message) await resolver.GetMetadataReferenceAsync("System.Private.CoreLib.wasm"), await resolver.GetMetadataReferenceAsync("System.Runtime.wasm"), await resolver.GetMetadataReferenceAsync("System.Console.wasm"), + await resolver.GetMetadataReferenceAsync("System.Linq.wasm"), + await resolver.GetMetadataReferenceAsync("System.Collections.wasm"), + await resolver.GetMetadataReferenceAsync("System.Text.Json.wasm"), await resolver.GetMetadataReferenceAsync("xunit.assert.wasm"), await resolver.GetMetadataReferenceAsync("xunit.core.wasm"), await resolver.GetMetadataReferenceAsync("Apollo.Hosting.wasm"), @@ -203,8 +206,23 @@ async Task HandleRouteMessage(WorkerMessage message) var request = JsonSerializer.Deserialize(message.Payload); if (request != null) { - var response = WebApplication.Current.HandleRequest(request.Route, request.Content, request.Method); - await SendResponse(response); + try + { + if (WebApplication.Current == null) + { + loggerBridge.LogError("WebApplication.Current is null - API not running"); + await SendResponse(request.RequestId ?? "", "API not running", 503); + return; + } + + var response = WebApplication.Current.HandleRequest(request.Route, request.Content, request.Method); + await SendResponse(request.RequestId ?? "", response, 200); + } + catch (Exception ex) + { + loggerBridge.LogError($"Error handling route: {ex.Message}"); + await SendResponse(request.RequestId ?? "", ex.Message, 500); + } } } @@ -218,12 +236,13 @@ async Task SendError(string errorMessage) Imports.PostMessage(msg.ToSerialized()); } -async Task SendResponse(string response) +async Task SendResponse(string requestId, string body, int statusCode) { + var response = new RouteResponse(requestId, body, statusCode); var msg = new WorkerMessage { Action = WorkerActions.RouteResponse, - Payload = response + Payload = JsonSerializer.Serialize(response) }; Imports.PostMessage(msg.ToSerialized()); } \ No newline at end of file diff --git a/Apollo.Hosting/MinimalApiTransformer.cs b/Apollo.Hosting/MinimalApiTransformer.cs index a737ce2..8e77569 100644 --- a/Apollo.Hosting/MinimalApiTransformer.cs +++ b/Apollo.Hosting/MinimalApiTransformer.cs @@ -9,16 +9,62 @@ public static class MinimalApiTransformer { public static string WrapMinimalApi(string code) { + var (mainCode, classDefinitions) = ExtractClassDefinitions(code); + return $$""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.Json; using Microsoft.AspNetCore.Builder; + {{classDefinitions}} + public class Program { public static void Main(string[] args) { - {{code}} + {{mainCode}} } } """; } + + private static (string mainCode, string classDefinitions) ExtractClassDefinitions(string code) + { + var lines = code.Split('\n'); + var mainLines = new List(); + var classLines = new List(); + var inClassDefinition = false; + var braceCount = 0; + + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + + if (!inClassDefinition && (trimmed.StartsWith("class ") || trimmed.StartsWith("public class ") || + trimmed.StartsWith("record ") || trimmed.StartsWith("public record "))) + { + inClassDefinition = true; + braceCount = 0; + } + + if (inClassDefinition) + { + classLines.Add(line); + braceCount += line.Count(c => c == '{') - line.Count(c => c == '}'); + + if (braceCount <= 0 && line.Contains('}')) + { + inClassDefinition = false; + } + } + else + { + mainLines.Add(line); + } + } + + return (string.Join('\n', mainLines), string.Join('\n', classLines)); + } } \ No newline at end of file diff --git a/Apollo.Hosting/RouteRequest.cs b/Apollo.Hosting/RouteRequest.cs index 19daf3c..0b69d91 100644 --- a/Apollo.Hosting/RouteRequest.cs +++ b/Apollo.Hosting/RouteRequest.cs @@ -2,4 +2,6 @@ namespace Apollo.Hosting; -public record RouteRequest(HttpMethodType Method, string Route, string? Content = default); \ No newline at end of file +public record RouteRequest(HttpMethodType Method, string Route, string? Content = default, string? RequestId = default); + +public record RouteResponse(string RequestId, string Body, int StatusCode = 200); \ No newline at end of file