JSON-RPC in ASP.NET Core With StreamJsonRpc

I've been pointed to StreamJsonRpc library through a comment under one of my posts and I've decided it's something worth taking a look at. StreamJsonRpc is a .NET Standard implementation of JSON-RPC 2.0 wire protocol.

The question that probably comes to your mind right now is why one should spend time to take a look at yet another RPC technology? After all ASP.NET Core already has SignalR and gRPC (I would like to add RSocket to that list but at this point, it looks like the .NET implementation is going nowhere). Well, SignalR doesn't have a nice way to perform an invocation and receive a response from it. gRPC has very specific requirements regarding transport. gRPC-Web improves that and its ASP.NET Core implementation is very nice, but it's still not perfect. In this context, JSON-RPC 2.0 provides simple invocations with support for responses and is transport agnostic. The .NET implementation will happily work with Stream, WebSocket, or IDuplexPipe. There are also some very nice additions like out-of-the-box support for IProgress and IAsyncEnumerable.

Because of how flexible StreamJsonRpc is when it comes to the underlying transport, there are two main options for hosting it in an ASP.NET Core application.

Standalone Service Using IDuplexPipe

The first option is to use networking primitives for non-HTTP Servers and build a standalone service providing JSON-RPC over TCP on top of bare Kestrel.

Let's assume this will be our server (any similarity to a gRPC sample is totally intended).

public class HelloRequest
{
    public string Name { get; set; }
}

public class HelloReply
{
    public string Message { get; set; }
}

public interface IGreeter
{
    Task<HelloReply> SayHelloAsync(HelloRequest request);
}

public class GreeterServer : IGreeter
{
    public Task<HelloReply> SayHelloAsync(HelloRequest request)
    {
        return Task.FromResult(new HelloReply
        {
            Message = "Hello " + request.Name
        });
    }
}

To host this server we will need a BackgroundService which will manage incoming connections. There is a general pattern which such BackgroundService should follow.

internal class StreamJsonRpcHost : BackgroundService
{
    private readonly IConnectionListenerFactory _connectionListenerFactory;
    private readonly ConcurrentDictionary<string, (ConnectionContext Context, Task ExecutionTask)> _connections =
        new ConcurrentDictionary<string, (ConnectionContext, Task)>();
    private readonly ILogger<StreamJsonRpcHost> _logger;

    private IConnectionListener _connectionListener;

    public StreamJsonRpcHost(IConnectionListenerFactory connectionListenerFactory, ILogger<StreamJsonRpcHost> logger)
    {
        _connectionListenerFactory = connectionListenerFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _connectionListener = await _connectionListenerFactory.BindAsync(new IPEndPoint(IPAddress.Loopback, 6000), stoppingToken);

        while (true)
        {
            ConnectionContext connectionContext = await _connectionListener.AcceptAsync(stoppingToken);

            if (connectionContext == null)
            {
                break;
            }

            _connections[connectionContext.ConnectionId] = (connectionContext, AcceptAsync(connectionContext));
        }

        List<Task> connectionsExecutionTasks = new List<Task>(_connections.Count);

        foreach (var connection in _connections)
        {
            connectionsExecutionTasks.Add(connection.Value.ExecutionTask);
            connection.Value.Context.Abort();
        }

        await Task.WhenAll(connectionsExecutionTasks);
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        await _connectionListener.DisposeAsync();
    }

    private async Task AcceptAsync(ConnectionContext connectionContext)
    {
        ...
    }
}

The important part is AcceptAsync. This is where the incoming connection needs to be passed to a new instance JsonRpc. This requires making two choices: which message handler and message formatter should be used.

There are several options for message handler, in the below code I'm going for LengthHeaderMessageHandler because it's described as the fastest for streams and pipelines.

When it comes to message formatter, there are only two out-of-box-options: JSON and MessagePack. I've decided to stick with JSON.

internal class StreamJsonRpcHost : BackgroundService
{
    ...

    private async Task AcceptAsync(ConnectionContext connectionContext)
    {
        try
        {
            await Task.Yield();

            IJsonRpcMessageFormatter jsonRpcMessageFormatter = new JsonMessageFormatter(Encoding.UTF8);
            IJsonRpcMessageHandler jsonRpcMessageHandler = new LengthHeaderMessageHandler(
                connectionContext.Transport,
                jsonRpcMessageFormatter);

            using (var jsonRpc = new JsonRpc(jsonRpcMessageHandler, new GreeterServer()))
            {
                jsonRpc.StartListening();

                await jsonRpc.Completion;
            }

            await connectionContext.ConnectionClosed.WaitAsync();
        }
        ...
        finally
        {
            await connectionContext.DisposeAsync();

            _connections.TryRemove(connectionContext.ConnectionId, out _);
        }
    }
}

Those couple lines of code (there is additional error handling which I've omitted here) is all we need to have a fully working JSON-RPC 2.0 client. Of course, it would be nice to have a client as well.

The client implementation isn't much harder. After opening a stream to the server we need to attach JsonRpc to it through a handler. The Attach method can be provided interface of our server which will allow us to work with it in a strongly typed approach. In order to disconnect, we close the stream.

class Program
{
    static async Task Main(string[] args)
    {
        TcpClient tcpClient = new TcpClient("localhost", 6000);

        Stream jsonRpcStream = tcpClient.GetStream();
        IJsonRpcMessageFormatter jsonRpcMessageFormatter = new JsonMessageFormatter(Encoding.UTF8);
        IJsonRpcMessageHandler jsonRpcMessageHandler = new LengthHeaderMessageHandler(
            jsonRpcStream,
            jsonRpcStream,
            jsonRpcMessageFormatter);

        IGreeter jsonRpcGreeterClient = JsonRpc.Attach<IGreeter>(jsonRpcMessageHandler);

        HelloReply helloReply = await jsonRpcGreeterClient.SayHelloAsync(new HelloRequest { Name = "Tomasz Pęczek" });

        Console.WriteLine(helloReply.Message);

        jsonRpcStream.Close();

        Console.ReadKey();
    } 
}

Part of a Web Application Using WebSocket

The second option is to have a JSON-RPC endpoint as a part of a web application.

The transport, in this case, will be WebSocket and all we need is a middleware.

internal class StreamJsonRpcMiddleware
{
    public StreamJsonRpcMiddleware(RequestDelegate next)
    { }

    public async Task Invoke(HttpContext context)
    {
        if (context.WebSockets.IsWebSocketRequest)
        {
            var webSocket = await context.WebSockets.AcceptWebSocketAsync();

            IJsonRpcMessageHandler jsonRpcMessageHandler = new WebSocketMessageHandler(webSocket);

            using (var jsonRpc = new JsonRpc(jsonRpcMessageHandler, new GreeterServer()))
            {
                jsonRpc.StartListening();

                await jsonRpc.Completion;
            }
        }
        else
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
        }
    }
}

The only difference in setting up JsonRpc is the message handler. There is a predefined WebSocketMessageHandler which makes proper use of WebSocket protocol features like implicit message boundaries. There is an option of choosing a message formatter here as well, but I've decided to stick with the default one.

After mapping this middleware to an endpoint, it will start receiving requests. In order to make one, we need a slight modification to the client.

static async Task Main(string[] args)
{
    using (var webSocket = new ClientWebSocket())
    {
        await webSocket.ConnectAsync(new Uri("ws://localhost:5000/json-rpc-greeter"), CancellationToken.None);

        IJsonRpcMessageHandler jsonRpcMessageHandler = new WebSocketMessageHandler(webSocket);

        IGreeter jsonRpcGreeterClient = JsonRpc.Attach<IGreeter>(jsonRpcMessageHandler);

        HelloReply helloReply = await jsonRpcGreeterClient.SayHelloAsync(new HelloRequest { Name = "Tomasz Pęczek" });

        Console.WriteLine(helloReply.Message);

        await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closing", CancellationToken.None);
    }

    Console.ReadKey();
}

When we use WebSocket protocol as a transport, we also make it possible to use that JSON-RPC endpoint from the frontend. There are ready to use JavaScript libraries and "from scratch" implementation is also not that hard.

In Summary

StreamJsonRpc is very easy to use and flexible. For me, it fills a small gap between other RPC technologies we have for ASP.NET Core. In several situations when I struggled to bend SignalR to my will and couldn't use gRPC due to technical limitations, this would be the answer. I strongly encourage you to add it to your toolbox. To help you with that I've put the entire sample code on GitHub.