/SimpleR

High Performance Pure WebSocket Server Library based on SignalR

Primary LanguageC#MIT LicenseMIT

GitHub Cover

SimpleR NuGet Version

SimpleR is a streamlined version of SignalR, a high-performance, opinionated, real-time web framework. By removing all the custom protocols from SignalR, we are left with a simpler library, hence the name SimpleR.

When should I use SimpleR?

In short, If you can use SignalR, you should. If not, go with SimpleR.

SimpleR was created to address the need for an easy-to-use, high-performance WebSocket server on .NET, particularly in scenarios where the client cannot use SignalR. For instance, when the client is an IoT device operating with a specific protocol standard over which you have no control (OCPP for example), SignalR may not be an option. In such cases, you're often left with very low-level programming APIs. SimpleR aims to solve this problem by providing simpler and more familiar APIs to expedite your high-performance WebSocket server development.

Standard Protocols

  • OCPP NuGet Version

Examples

Examples can be found here

Getting Started

SimpleR can be installed using the Nuget package manager or the dotnet CLI.

dotnet add package SimpleR.Server --prerelease

Configure SimpleR

Here is a simple configuration example.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSimpleR();

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapSimpleR<ThermostatMetric, ThermostatCommand>("thermostat/{deviceId}", b =>
{
    b.UseDispatcher<ThermostatMessageDispatcher>()
        .UseEndOfMessageDelimitedProtocol(new ThermostatMessageProtocol());
})
.RequireAuthorization();

app.Run();

The preceding code adds SimpleR to the ASP.NET Core dependency injections, routing systems and defines the message protocol and the message dispatcher.

Create a Message Dispatcher

A message dispatcher is a high-level pipeline that encapsulates the logic of where to dispatch connection messages.

public class ThermostatMessageDispatcher : IWebSocketMessageDispatcher<ThermostatMetric, ThermostatCommand>
{
    
    /// <summary>
    /// Called when a connection is established.
    /// </summary>
    /// <param name="connection">The connection.</param>
    public Task OnConnectedAsync(IWebsocketConnectionContext<ThermostatCommand> connection)
    {
        return Task.CompletedTask;
    }

    /// <summary>
    /// Called when a connection is disconnected.
    /// </summary>
    /// <param name="connection">The connection.</param>
    /// <param name="exception">The exception that occurred, if any.</param>
    public Task OnDisconnectedAsync(IWebsocketConnectionContext<ThermostatCommand> connection, Exception? exception)
    {
        return Task.CompletedTask;
    }

    /// <summary>
    /// Dispatches a message to the application.
    /// </summary>
    /// <param name="connection">The connection.</param>
    /// <param name="message">The message to dispatch.</param>
    public async Task DispatchMessageAsync(IWebsocketConnectionContext<ThermostatCommand> connection, ThermostatMetric message)
    {
        var deviceId = connection.User.FindFirstValue(ClaimTypes.Name) ?? throw new InvalidOperationException("Current user is not a device");
        var settings = GetSettings(deviceId);
        if(message is ThermostatTemperatureMetric temperatureMetric)
        {
            if (temperatureMetric.Temperature < settings.TargetTemperature)
            {
                // If the temperature is below the target temperature, set the thermostat to heat mode
                await connection.WriteAsync(new SetThermostatModeCommand(ThermostatMode.Heat));
            }
            else if (temperatureMetric.Temperature > settings.TargetTemperature)
            {
                // If the temperature is above the target temperature, set the thermostat to cool mode
                await connection.WriteAsync(new SetThermostatModeCommand(ThermostatMode.Cool));
            }
            else
            {
                // If the temperature is at the target temperature, turn off the thermostat
                await connection.WriteAsync(new SetThermostatModeCommand(ThermostatMode.Off));
            }
        }
    }
}

Each SimpleR route has one message dispatcher instance.

Defining Message Protocols

Since SimpleR is protocol-agnostic, it requires the user to provide a protocol definition to be able to construct a message from the stream of bytes each connection receives. There are two categories of a message protocol:

EndOfMessage Delimited Protocol

Here is a simple delimited protocol implementation:

public class ThermostatMessageProtocol: IDelimitedMessageProtocol<ThermostatMetric, ThermostatCommand>
{
    public ThermostatMetric ParseMessage(ref ReadOnlySequence<byte> input)
    {
        var jsonReader = new Utf8JsonReader(input);

        return JsonSerializer.Deserialize<ThermostatMetric>(ref jsonReader)!;
    }
    
    public void WriteMessage(ThermostatCommand message, IBufferWriter<byte> output)
    {
        var jsonWriter = new Utf8JsonWriter(output);
        JsonSerializer.Serialize(jsonWriter, message);
    }
}

To use the delimited protocol call the UseEndOfMessageDelimitedProtocol method of the builder.

app.MapSimpleR<ThermostatMetric, ThermostatCommand>("thermostat/{deviceId}", b =>
{
    b.UseDispatcher<ThermostatMessageDispatcher>()
        .UseEndOfMessageDelimitedProtocol(new ThermostatMessageProtocol());
})

Custom Protocol

Here is a simple custom protocol implementation:

public class ChatMessageProtocol : IMessageProtocol<ChatMessage>
{

    public void WriteMessage(ChatMessage message, IBufferWriter<byte> output)
    {
        var span = output.GetSpan(Encoding.UTF8.GetByteCount(message.Content));

        var bytesWritten = Encoding.UTF8.GetBytes(message.Content, span);

        output.Advance(bytesWritten);
    }

    public bool TryParseMessage(ref ReadOnlySequence<byte> input, out ChatMessage message)
    {
        var reader = new SequenceReader<byte>(input);

        if (reader.TryReadTo(out ReadOnlySequence<byte> payload, delimiter: 0, advancePastDelimiter: true))
        {
            message = new ChatMessage { Content = Encoding.UTF8.GetString(payload) };
            input = reader.UnreadSequence;
            return true;
        }

        message = default;
        return false;
    }
}

To use the delimited protocol call the UseCustomProtocol method of the builder.

app.MapSimpleR<ChatMessage>("/chat",
    b =>
    {
        b.UseCustomProtocol(new ChatMessageProtocol())
        .UseDispatcher<ChatMessageDispatcher>();
    }
);

How to work with low level network buffers

To learn more about working with ReadOnlySequence<T> and IBufferWriter<T> check out this article.