#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see .
#endregion
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
namespace VocaluxeWeb.Services
{
/// Pushes events to all connected WebApps using Server-Sent Events (text/event-stream).
/// Data only flows from the server to the clients; changes are made with the REST API.
/// Browsers reconnect on their own when the connection drops.
public sealed class CLiveEvents
{
private static readonly TimeSpan _Heartbeat = TimeSpan.FromSeconds(15);
private static readonly JsonSerializerSettings _Json = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = { new StringEnumConverter() }
};
private sealed class CSubscriber
{
public readonly ConcurrentQueue Messages = new ConcurrentQueue();
public readonly SemaphoreSlim Signal = new SemaphoreSlim(0);
}
private readonly ConcurrentDictionary _Subscribers = new ConcurrentDictionary();
public int ClientCount => _Subscribers.Count;
public void Publish(string type, object data)
{
var message = _Format(type, data);
foreach (var subscriber in _Subscribers.Values)
{
subscriber.Messages.Enqueue(message);
subscriber.Signal.Release();
}
}
/// Streams events to one client until it disconnects or returns false (e.g. after logout).
public async Task StreamAsync(HttpResponse response, IEnumerable<(string Type, object Data)> initial, Func isValid, CancellationToken cancel)
{
response.ContentType = "text/event-stream";
response.Headers["Cache-Control"] = "no-cache";
response.Headers["X-Accel-Buffering"] = "no";
var id = Guid.NewGuid();
var subscriber = new CSubscriber();
_Subscribers[id] = subscriber;
try
{
//Ask the browser to wait 3 s before reconnecting
await _WriteAsync(response, "retry: 3000\n\n", cancel);
foreach (var (type, data) in initial)
{
await _WriteAsync(response, _Format(type, data), cancel);
}
while (!cancel.IsCancellationRequested)
{
if (!await subscriber.Signal.WaitAsync(_Heartbeat, cancel))
{
if (!isValid())
{
return;
}
await _WriteAsync(response, ": ping\n\n", cancel);
continue;
}
var builder = new StringBuilder();
while (subscriber.Messages.TryDequeue(out var message))
{
builder.Append(message);
}
if (builder.Length > 0)
{
await _WriteAsync(response, builder.ToString(), cancel);
}
}
}
catch (OperationCanceledException)
{
//The client disconnected
}
catch (Exception) when (cancel.IsCancellationRequested)
{
//Writing failed because the client disconnected
}
finally
{
_Subscribers.TryRemove(id, out _);
}
}
private static string _Format(string type, object data)
{
return "event: " + type + "\ndata: " + JsonConvert.SerializeObject(data, _Json) + "\n\n";
}
private static async Task _WriteAsync(HttpResponse response, string text, CancellationToken cancel)
{
var bytes = Encoding.UTF8.GetBytes(text);
await response.Body.WriteAsync(bytes, 0, bytes.Length, cancel);
await response.Body.FlushAsync(cancel);
}
}
}