Skip to content

Commit c6c9655

Browse files
authored
Merge pull request #57 from Mythetech/feat/full-stack-dev
feat: full stack development
2 parents 3b5eea2 + 35d1cda commit c6c9655

28 files changed

Lines changed: 1577 additions & 20 deletions

Apollo.Client/Hosting/HostingWorkerProxy.cs

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using System.Text.Json;
23
using Apollo.Components.Console;
34
using Apollo.Components.Hosting;
@@ -16,8 +17,10 @@ public class HostingWorkerProxy : IHostingWorker
1617
{
1718
private readonly SlimWorker _worker;
1819
private readonly Dictionary<string, Delegate> _callbacks = new();
20+
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _pendingRequests = new();
1921
private readonly IJSRuntime _jsRuntime;
2022
private readonly WebHostConsoleService _console;
23+
private int _requestCounter;
2124

2225
private static readonly JsonSerializerOptions SerializerOptions = new()
2326
{
@@ -39,7 +42,6 @@ internal async Task InitializeMessageListener()
3942
object? data = await e.Data.GetValueAsync();
4043
if (data is string json)
4144
{
42-
//_console.AddDebug($"Raw message received: {json}");
4345
var message = JsonSerializer.Deserialize<WorkerMessage>(json);
4446
if (message == null)
4547
{
@@ -106,10 +108,9 @@ internal async Task InitializeMessageListener()
106108
}
107109
break;
108110
case WorkerActions.RouteResponse:
109-
_console.AddInfo($"Response received: {message.Payload}");
111+
HandleRouteResponse(message.Payload);
110112
break;
111113
case "":
112-
113114
break;
114115

115116
default:
@@ -124,6 +125,34 @@ internal async Task InitializeMessageListener()
124125
await _worker.AddOnMessageEventListenerAsync(eventListener);
125126
}
126127

128+
private void HandleRouteResponse(string payload)
129+
{
130+
try
131+
{
132+
var response = JsonSerializer.Deserialize<RouteResponse>(payload, SerializerOptions);
133+
if (response == null)
134+
{
135+
_console.AddWarning($"Failed to deserialize route response: {payload}");
136+
return;
137+
}
138+
139+
_console.AddInfo($"Response received for {response.RequestId}: {response.StatusCode}");
140+
141+
if (_pendingRequests.TryRemove(response.RequestId, out var tcs))
142+
{
143+
tcs.TrySetResult(response.Body);
144+
}
145+
else
146+
{
147+
_console.AddWarning($"No pending request found for {response.RequestId}");
148+
}
149+
}
150+
catch (Exception ex)
151+
{
152+
_console.AddError($"Error handling route response: {ex.Message}");
153+
}
154+
}
155+
127156
public void OnLog(Func<LogMessage, Task> callback)
128157
{
129158
_callbacks[StandardWorkerActions.Log] = callback;
@@ -156,16 +185,32 @@ public async Task RunAsync(string code)
156185
await _worker.PostMessageAsync(msg.ToSerialized());
157186
}
158187

159-
public async Task SendAsync(HttpMethodType method, string path, string? body = default)
188+
public async Task<string> SendAsync(HttpMethodType method, string path, string? body = default)
160189
{
161-
var request = new RouteRequest(method, path, body);
190+
var requestId = $"req_{Interlocked.Increment(ref _requestCounter)}_{DateTimeOffset.UtcNow.Ticks}";
191+
var tcs = new TaskCompletionSource<string>();
192+
193+
_pendingRequests[requestId] = tcs;
194+
195+
var request = new RouteRequest(method, path, body, requestId);
162196
var msg = new WorkerMessage()
163197
{
164198
Action = WorkerActions.Send,
165199
Payload = JsonSerializer.Serialize(request, SerializerOptions)
166200
};
167201

168202
await _worker.PostMessageAsync(msg.ToSerialized());
203+
204+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
205+
cts.Token.Register(() =>
206+
{
207+
if (_pendingRequests.TryRemove(requestId, out var pendingTcs))
208+
{
209+
pendingTcs.TrySetException(new TimeoutException($"Request {requestId} timed out"));
210+
}
211+
});
212+
213+
return await tcs.Task;
169214
}
170215

171216
public async Task StopAsync()
@@ -177,4 +222,4 @@ public async Task StopAsync()
177222
};
178223
await _worker.PostMessageAsync(msg.ToSerialized());
179224
}
180-
}
225+
}

Apollo.Client/wwwroot/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
<script src="_content/BlazorMonaco/lib/monaco-editor/min/vs/editor/editor.main.js"></script>
6363
<script src="_framework/blazor.webassembly.js"></script>
6464
<script src="_content/Apollo.Components/app.js" type="module"></script>
65+
<script src="_content/Apollo.Components/client-preview.js"></script>
6566
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
6667
<script>navigator.serviceWorker.register('service-worker.js');</script>
6768
<script src="/_framework/aspnetcore-browser-refresh.js"></script>
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
@inherits Apollo.Components.DynamicTabs.DynamicTabView
2+
@using Apollo.Components.DynamicTabs
3+
@using Apollo.Components.DynamicTabs.Commands
4+
@using Apollo.Components.DynamicClient.Commands
5+
@using Apollo.Components.Hosting
6+
@using Apollo.Components.Hosting.Commands
7+
@using Apollo.Components.Infrastructure.MessageBus
8+
@using Apollo.Components.Shared
9+
@using Apollo.Components.Solutions
10+
@using Apollo.Components.Solutions.Commands
11+
@using Apollo.Components.Theme
12+
@using Apollo.Contracts.Solutions
13+
@using Microsoft.JSInterop
14+
@implements IAsyncDisposable
15+
16+
<div class="d-flex flex-column mud-height-full" style="background: var(--mud-palette-background);">
17+
<div class="client-toolbar d-flex align-center gap-2 pa-2"
18+
style="background: var(--mud-palette-surface); border-bottom: 1px solid var(--mud-palette-lines-default);">
19+
<ApolloIconButton Icon="@(_isLoading? Icons.Material.Filled.HourglassEmpty : Icons.Material.Filled.Refresh)"
20+
Tooltip="Refresh" Size="Size.Small" Disabled="@_isLoading" OnClick="RefreshPreview" />
21+
22+
<div class="url-bar flex-grow-1 d-flex align-center px-2 py-1 rounded"
23+
style="background: var(--mud-palette-background); border: 1px solid var(--mud-palette-lines-inputs);">
24+
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="mr-2"
25+
Style="color: var(--mud-palette-success);" />
26+
<MudText Typo="Typo.body2" Class="flex-grow-1">localhost (virtual)</MudText>
27+
</div>
28+
29+
<MudTooltip Text="Open in floating window">
30+
<ApolloIconButton Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small"
31+
OnClick="@(async () => await Bus.PublishAsync(new UpdateTabLocationByName(Name, DropZones.Floating)))" />
32+
</MudTooltip>
33+
</div>
34+
35+
<div class="preview-container flex-grow-1 position-relative" style="overflow: hidden;">
36+
@if (!HostingService.Hosting)
37+
{
38+
<div class="d-flex flex-column align-center justify-center mud-height-full gap-4 pa-4">
39+
<MudIcon Icon="@Icons.Material.Filled.CloudOff" Size="Size.Large"
40+
Style="color: var(--mud-palette-text-secondary);" />
41+
<MudText Typo="Typo.h6">API Server Not Running</MudText>
42+
<MudText Typo="Typo.body2" Class="mud-text-secondary text-center" Style="max-width: 300px;">
43+
Start your Web API project first, then the client preview will connect automatically.
44+
</MudText>
45+
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@ApolloIcons.Run"
46+
Disabled="@(State.Project?.ProjectType != ProjectType.WebApi)" OnClick="StartApiAndClient">
47+
Run Full-Stack
48+
</MudButton>
49+
</div>
50+
}
51+
else if (string.IsNullOrEmpty(_documentContent))
52+
{
53+
<div class="d-flex flex-column align-center justify-center mud-height-full gap-4 pa-4">
54+
<MudIcon Icon="@Icons.Material.Filled.WebAsset" Size="Size.Large"
55+
Style="color: var(--mud-palette-text-secondary);" />
56+
<MudText Typo="Typo.h6">No Client Found</MudText>
57+
<MudText Typo="Typo.body2" Class="mud-text-secondary text-center" Style="max-width: 300px;">
58+
Add an <code>index.html</code> file to your solution to enable client preview.
59+
</MudText>
60+
</div>
61+
}
62+
else
63+
{
64+
<iframe @ref="_iframeRef" class="client-iframe" sandbox="allow-scripts allow-forms allow-modals"
65+
style="width: 100%; height: 100%; border: none; background: white;">
66+
</iframe>
67+
}
68+
</div>
69+
70+
@if (_requestCount > 0)
71+
{
72+
<div class="status-bar d-flex align-center justify-space-between px-2 py-1"
73+
style="background: var(--mud-palette-surface); border-top: 1px solid var(--mud-palette-lines-default); font-size: 0.75rem;">
74+
<MudText Typo="Typo.caption">@_requestCount requests</MudText>
75+
<MudLink Typo="Typo.caption" OnClick="@(() => Bus.PublishAsync(new FocusTab("Network")))">
76+
View Network Log
77+
</MudLink>
78+
</div>
79+
}
80+
</div>
81+
82+
@code {
83+
[Inject] private IDynamicClientService ClientService { get; set; } = default!;
84+
[Inject] private IHostingService HostingService { get; set; } = default!;
85+
[Inject] private SolutionsState State { get; set; } = default!;
86+
[Inject] private IMessageBus Bus { get; set; } = default!;
87+
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
88+
89+
private ElementReference _iframeRef;
90+
private DotNetObjectReference<ClientPreviewTab>? _dotNetRef;
91+
private string? _documentContent;
92+
private bool _isLoading;
93+
private int _requestCount;
94+
private bool _jsInitialized;
95+
96+
public override string Name { get; set; } = "Client Preview";
97+
public override Type ComponentType { get; set; } = typeof(ClientPreviewTab);
98+
public override string DefaultArea => DropZones.None;
99+
100+
protected override async Task OnInitializedAsync()
101+
{
102+
await base.OnInitializedAsync();
103+
104+
HostingService.OnHostingStateChanged += HandleHostingStateChanged;
105+
HostingService.OnRoutesChanged += HandleRoutesChanged;
106+
ClientService.OnRequestLogged += HandleRequestLogged;
107+
State.SolutionFilesChanged += HandleFilesChanged;
108+
109+
if (HostingService.Hosting && HostingService.Routes?.Count > 0)
110+
{
111+
await LoadClientDocument();
112+
}
113+
}
114+
115+
private async Task HandleRoutesChanged()
116+
{
117+
if (HostingService.Routes?.Count > 0)
118+
{
119+
await LoadClientDocument();
120+
}
121+
await InvokeAsync(StateHasChanged);
122+
}
123+
124+
protected override async Task OnAfterRenderAsync(bool firstRender)
125+
{
126+
if (firstRender)
127+
{
128+
_dotNetRef = DotNetObjectReference.Create(this);
129+
}
130+
131+
if (!string.IsNullOrEmpty(_documentContent) && !_jsInitialized)
132+
{
133+
await InitializeIframe();
134+
}
135+
}
136+
137+
private async Task InitializeIframe()
138+
{
139+
if (_dotNetRef == null) return;
140+
141+
try
142+
{
143+
await JsRuntime.InvokeVoidAsync("apolloClientPreview.initialize", _iframeRef, _dotNetRef, _documentContent);
144+
_jsInitialized = true;
145+
}
146+
catch (Exception ex)
147+
{
148+
Console.WriteLine($"Failed to initialize iframe: {ex.Message}");
149+
}
150+
}
151+
152+
[JSInvokable]
153+
public async Task<object> HandleApiRequest(int id, string method, string url, string? body)
154+
{
155+
var response = await ClientService.HandleRequestAsync(method, url, body);
156+
_requestCount++;
157+
await InvokeAsync(StateHasChanged);
158+
159+
return new
160+
{
161+
id,
162+
status = response.StatusCode,
163+
body = response.Body,
164+
headers = response.Headers ?? new Dictionary<string, string> { { "Content-Type", "application/json" } }
165+
};
166+
}
167+
168+
private async Task HandleHostingStateChanged()
169+
{
170+
if (!HostingService.Hosting)
171+
{
172+
_documentContent = null;
173+
_jsInitialized = false;
174+
}
175+
176+
await InvokeAsync(StateHasChanged);
177+
}
178+
179+
private void HandleRequestLogged(NetworkRequest request)
180+
{
181+
_requestCount = ClientService.RequestLog.Count;
182+
InvokeAsync(StateHasChanged);
183+
}
184+
185+
private void HandleFilesChanged()
186+
{
187+
if (HostingService.Hosting && State.Project != null)
188+
{
189+
_ = RefreshPreview();
190+
}
191+
}
192+
193+
private async Task LoadClientDocument()
194+
{
195+
if (State.Project == null) return;
196+
197+
_documentContent = ClientService.BuildClientDocument(State.Project);
198+
_jsInitialized = false;
199+
await InvokeAsync(StateHasChanged);
200+
}
201+
202+
private async Task RefreshPreview()
203+
{
204+
_isLoading = true;
205+
StateHasChanged();
206+
207+
try
208+
{
209+
_jsInitialized = false;
210+
await LoadClientDocument();
211+
212+
if (!string.IsNullOrEmpty(_documentContent))
213+
{
214+
await Task.Delay(50);
215+
await InitializeIframe();
216+
}
217+
}
218+
finally
219+
{
220+
_isLoading = false;
221+
StateHasChanged();
222+
}
223+
}
224+
225+
private async Task StartApiAndClient()
226+
{
227+
if (State.Project == null) return;
228+
229+
await Bus.PublishAsync(new StartRunning());
230+
await ClientService.StartAsync(State.Project);
231+
}
232+
233+
public async ValueTask DisposeAsync()
234+
{
235+
HostingService.OnHostingStateChanged -= HandleHostingStateChanged;
236+
HostingService.OnRoutesChanged -= HandleRoutesChanged;
237+
ClientService.OnRequestLogged -= HandleRequestLogged;
238+
State.SolutionFilesChanged -= HandleFilesChanged;
239+
240+
_dotNetRef?.Dispose();
241+
242+
try
243+
{
244+
await JsRuntime.InvokeVoidAsync("apolloClientPreview.dispose");
245+
}
246+
catch
247+
{
248+
}
249+
}
250+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace Apollo.Components.DynamicClient.Commands;
2+
3+
public record RefreshClient;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using Apollo.Components.Solutions;
2+
3+
namespace Apollo.Components.DynamicClient.Commands;
4+
5+
public record StartClient(SolutionModel Solution, string? EntryFile = null);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace Apollo.Components.DynamicClient.Commands;
2+
3+
public record StopClient;

0 commit comments

Comments
 (0)