diff --git a/HoolIt/Dockerfile b/HoolIt/Dockerfile
index 8365ff2..41f4791 100644
--- a/HoolIt/Dockerfile
+++ b/HoolIt/Dockerfile
@@ -1,23 +1,16 @@
-FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
-USER $APP_UID
-WORKDIR /app
-EXPOSE 8080
-EXPOSE 8081
+FROM mcr.microsoft.com/dotnet/sdk:10.0.100-preview.2-alpine3.21 AS build
+
+# Install NativeAOT build prerequisites
+RUN apk update \
+ && apk add --no-cache \
+ clang zlib-dev
+
+WORKDIR /source
-FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
-ARG BUILD_CONFIGURATION=Release
-WORKDIR /src
-COPY ["HoolIt/HoolIt.csproj", "HoolIt/"]
-RUN dotnet restore "HoolIt/HoolIt.csproj"
COPY . .
-WORKDIR "/src/HoolIt"
-RUN dotnet build "HoolIt.csproj" -c $BUILD_CONFIGURATION -o /app/build
+RUN cd 'HoolIt' && dotnet publish -r linux-musl-x64 -o /app 'HoolIt.csproj'
-FROM build AS publish
-ARG BUILD_CONFIGURATION=Release
-RUN dotnet publish "HoolIt.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
-
-FROM base AS final
+FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.0-preview.2-alpine3.21
WORKDIR /app
-COPY --from=publish /app/publish .
-ENTRYPOINT ["dotnet", "HoolIt.dll"]
+COPY --from=build /app .
+ENTRYPOINT ["/app/HoolIt"]
\ No newline at end of file
diff --git a/HoolIt/HoolIt.csproj b/HoolIt/HoolIt.csproj
index 91a59c7..9302bd7 100644
--- a/HoolIt/HoolIt.csproj
+++ b/HoolIt/HoolIt.csproj
@@ -10,9 +10,9 @@
-
- .dockerignore
-
+
+ .dockerignore
+
diff --git a/HoolIt/Models/AddDweetFailedResponse.cs b/HoolIt/Models/AddDweetFailedResponse.cs
new file mode 100644
index 0000000..31f062d
--- /dev/null
+++ b/HoolIt/Models/AddDweetFailedResponse.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace HoolIt.Models;
+
+public class AddDweetFailedResponse
+{
+ [JsonPropertyName("this")] public string This { get; set; }
+
+ [JsonPropertyName("with")] public string With { get; set; }
+
+ [JsonPropertyName("because")] public string Because { get; set; }
+}
\ No newline at end of file
diff --git a/HoolIt/Models/AddDweetSucceededResponse.cs b/HoolIt/Models/AddDweetSucceededResponse.cs
new file mode 100644
index 0000000..647b5f7
--- /dev/null
+++ b/HoolIt/Models/AddDweetSucceededResponse.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace HoolIt.Models;
+
+public class AddDweetSucceededResponse
+{
+ [JsonPropertyName("this")] public string This { get; set; }
+
+ [JsonPropertyName("by")] public string By { get; set; }
+
+ [JsonPropertyName("the")] public string The { get; set; }
+
+ [JsonPropertyName("with")] public Dweet With { get; set; }
+}
\ No newline at end of file
diff --git a/HoolIt/Models/Dweet.cs b/HoolIt/Models/Dweet.cs
new file mode 100644
index 0000000..fd0545d
--- /dev/null
+++ b/HoolIt/Models/Dweet.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Primitives;
+
+namespace HoolIt.Models;
+
+public class Dweet
+{
+ [JsonPropertyName("thing")] public string Thing { get; set; }
+ [JsonPropertyName("created")] public DateTime Created { get; set; }
+ [JsonPropertyName("content")] public Dictionary Content { get; set; }
+}
\ No newline at end of file
diff --git a/HoolIt/Program.cs b/HoolIt/Program.cs
index 28badf5..feabd0b 100644
--- a/HoolIt/Program.cs
+++ b/HoolIt/Program.cs
@@ -1,6 +1,8 @@
using System.Collections.Concurrent;
using System.Text;
+using System.Text.Json;
using System.Text.Json.Serialization;
+using HoolIt.Models;
var builder = WebApplication.CreateSlimBuilder(args);
@@ -12,68 +14,122 @@ builder.Services.ConfigureHttpJsonOptions(options =>
var app = builder.Build();
var subscribers = new ConcurrentDictionary>();
+var cancellationSources =
+ new ConcurrentDictionary(); // To manage cancellation per feedId
// HAPI!
// https://github.com/jheising/HAPI
-var createApi = app.MapGroup("/create/with");
-createApi.MapGet("/{feedId}", async (HttpContext context,string feedId) =>
+var createApi = app.MapGroup("/dweet/for");
+createApi.MapGet("/{feedId}", async (HttpContext context, string feedId) =>
{
- var rawQueryData = context.Request.QueryString.ToString();
- var queryDataDic = context.Request.Query.ToDictionary(k => k.Key, v => v.Value);
- foreach (var a in queryDataDic)
+ var queryDataDic = context.Request.Query.ToDictionary(k => k.Key, v => v.Value[0]);
+ var dweet = new Dweet
{
- Console.WriteLine($"""{a.Key}: {a.Value}""");
- }
-
- if (subscribers.TryGetValue(feedId, out var subscribersList))
- {
- foreach (var writer in subscribersList)
- {
- await writer.WriteLineAsync(rawQueryData);
- await writer.FlushAsync();
- }
- }
-});
-
-var getLiveDataApi = app.MapGroup("/listen/for/data");
-getLiveDataApi.MapGet("/{feedId}", async (CancellationToken cancellationToken,HttpContext context, string feedId) =>
-{
- context.Response.Headers.ContentType = "text/event-stream";
-
- var writer = new StreamWriter(context.Response.Body, Encoding.UTF8);
- subscribers.GetOrAdd(feedId, _ => new List()).Add(writer);
+ Content = queryDataDic,
+ Created = DateTime.UtcNow,
+ Thing = feedId
+ };
try
{
- while (!cancellationToken.IsCancellationRequested)
- {
- await Task.Delay(Timeout.Infinite, cancellationToken);
- }
- }
- catch (OperationCanceledException)
- {
- }
- finally
- {
- if (subscribers.TryGetValue(feedId, out var subscribersList))
- {
- subscribersList.Remove(writer);
- if (subscribersList.Count == 0)
- {
- subscribers.TryRemove(feedId, out _);
- }
+ var chunkedQueryData = JsonSerializer.Serialize(dweet, AppJsonSerializerContext.Default.Dweet);
- await writer.DisposeAsync();
- Console.WriteLine("Removed subscriber from feed " + feedId);
- }
+ if (subscribers.TryGetValue(feedId, out var subscribersList))
+ foreach (var writer in subscribersList)
+ {
+ await writer.WriteLineAsync(chunkedQueryData);
+ await writer.FlushAsync();
+ }
}
+ catch (Exception e)
+ {
+ var faultResponse = new AddDweetFailedResponse()
+ {
+ This = "failed",
+ With = "WeMessedUp",
+ Because = "IDK, we couldnt dweet it. Report it at: https://github.com/mmahdium/HoolIt/issues"
+ };
+ var addFailedResponse =
+ JsonSerializer.Serialize(faultResponse, AppJsonSerializerContext.Default.AddDweetFailedResponse);
+ context.Response.StatusCode = 500; // Set the status code to 500
+ context.Response.ContentType = "application/json"; // Set Content-Type to application/json
+ await context.Response.WriteAsync(addFailedResponse); // Write the JSON error response to the body
+ await context.Response.CompleteAsync();
+
+ }
+
+ var addSuccessResponse = new AddDweetSucceededResponse
+ {
+ This = "succeeded",
+ By = "dweeting",
+ The = "dweet",
+ With = dweet
+ };
+ return Results.Ok(addSuccessResponse);
});
+
+var getLiveDataApi = app.MapGroup("/listen/for/dweets/from");
+getLiveDataApi.MapGet("/{feedId}",
+ async (HttpContext context, string feedId, IHostApplicationLifetime appLifetime,
+ CancellationToken reqCancellationToken) =>
+ {
+ context.Response.Headers.ContentType = "text/event-stream";
+
+ var writer = new StreamWriter(context.Response.Body, Encoding.UTF8);
+ subscribers.GetOrAdd(feedId, _ => new List()).Add(writer);
+
+ // How this cancellation token mess works:
+ // - reqCancellationToken is the cancellation token from the client request, it is used when a client closes the request.
+ // - feedCts is the cancellation token from the feedId, it is used when the app is shutting down.
+ // - linkedCts is a linked token source that combines both reqCancellationToken and feedCts and gets canceled when either of them does.
+ // When the app is shutting down, the feedCts token source is canceled which means everything gets canceled altogether (Even that date you have been planning for the past few months; c'mon, you are probably a computer science student with no friends who barely touches grass).
+
+ var feedCts = cancellationSources.GetOrAdd(feedId, _ => new CancellationTokenSource());
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(reqCancellationToken, feedCts.Token);
+ var linkedToken = linkedCts.Token;
+
+ appLifetime.ApplicationStopping.Register(() =>
+ {
+ if (cancellationSources.TryGetValue(feedId, out var existingFeedCts) &&
+ !existingFeedCts.IsCancellationRequested)
+ {
+ // It cancels
+ existingFeedCts.Cancel();
+ Console.WriteLine($"Cancellation signaled for feedId: {feedId} due to app shutdown.");
+ }
+ });
+
+ try
+ {
+ while (!linkedToken.IsCancellationRequested) await Task.Delay(Timeout.Infinite, linkedToken);
+ }
+ catch (OperationCanceledException)
+ {
+ Console.WriteLine($"SSE connection for feedId: {feedId} canceled.");
+ }
+ finally
+ {
+ if (subscribers.TryGetValue(feedId, out var subscribersList))
+ {
+ subscribersList.Remove(writer);
+ if (subscribersList.Count == 0)
+ {
+ subscribers.TryRemove(feedId, out _);
+ cancellationSources.TryRemove(feedId, out _);
+ Console.WriteLine($"No more subscribers for feedId: {feedId}. CTS removed.");
+ }
+
+ await writer.DisposeAsync();
+ Console.WriteLine("Removed subscriber from feed " + feedId);
+ }
+ }
+ });
+
app.Run();
-
-
-[JsonSerializable(typeof(string))]
-[JsonSerializable(typeof(Int32))]
+[JsonSerializable(typeof(Dweet))]
+[JsonSerializable(typeof(AddDweetSucceededResponse))]
+[JsonSerializable(typeof(AddDweetFailedResponse))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
\ No newline at end of file