Fully implement 'Real-time Streams' and 'Dweeting' features

This commit is contained in:
2025-04-01 15:37:06 +03:30
parent 9cb5e882c7
commit ab21341f8a
6 changed files with 157 additions and 71 deletions

View File

@@ -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"]

View File

@@ -10,9 +10,9 @@
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -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; }
}

View File

@@ -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; }
}

11
HoolIt/Models/Dweet.cs Normal file
View File

@@ -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<string, string> Content { get; set; }
}

View File

@@ -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<string, List<StreamWriter>>();
var cancellationSources =
new ConcurrentDictionary<string, CancellationTokenSource>(); // 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<StreamWriter>()).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<StreamWriter>()).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
{
}