mirror of
				https://github.com/mmahdium/HoolIt.git
				synced 2025-11-04 02:48:12 +01:00 
			
		
		
		
	Fully implement 'Real-time Streams' and 'Dweeting' features
This commit is contained in:
		@@ -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"]
 | 
			
		||||
@@ -10,9 +10,9 @@
 | 
			
		||||
    </PropertyGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
      <Content Include="..\.dockerignore">
 | 
			
		||||
        <Link>.dockerignore</Link>
 | 
			
		||||
      </Content>
 | 
			
		||||
        <Content Include="..\.dockerignore">
 | 
			
		||||
            <Link>.dockerignore</Link>
 | 
			
		||||
        </Content>
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
</Project>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								HoolIt/Models/AddDweetFailedResponse.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								HoolIt/Models/AddDweetFailedResponse.cs
									
									
									
									
									
										Normal 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; }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								HoolIt/Models/AddDweetSucceededResponse.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								HoolIt/Models/AddDweetSucceededResponse.cs
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										11
									
								
								HoolIt/Models/Dweet.cs
									
									
									
									
									
										Normal 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; }
 | 
			
		||||
}
 | 
			
		||||
@@ -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
 | 
			
		||||
{
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user