Building a cloud editor with SignalR and ASP.NET Core 2.1

With the release of 2.1, we finally got SignalR in ASP.NET Core. SignalR is used for making real-time connections on websites, which is used in applications where the response time is especially important and where you want the newest information at all times without having to poll the server for updates. I'll use the example of a multi-group collaborative text editor for this post. This will require both server-side code to manage connections, and a client using the updated SignalR.js client, which no longer relies on JQuery.

Here is a list of prerequisites you will need to install in order to get started with the project.

Launch Visual Studio and create a new ASP.NET Core Web Application.
Create .NET Core project
In the next window, pick ASP.NET Core 2.1 as your SDK, then choose the option “Web Application”
Pick ASP .NET Core 2.1

Create a new class and name it TextHub. This class will need the SignalR library, and to extend the Hub class. The hub is what controls the communication between different clients and will eventually manage the different groups. For now, just add the following functions which will manage a simple communication between clients:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace CollabTextEditor
{
    public class TextHub : Hub
    {
        public override async Task OnConnectedAsync()
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, "group1");
            await base.OnConnectedAsync();
        }

        public override async Task OnDisconnectedAsync(Exception exception)
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, "group1");
            await base.OnDisconnectedAsync(exception);
        }

        public async Task BroadcastText(string text)
        {
            await Clients.OthersInGroup("group1").SendAsync("ReceiveText", text);
        }
    }
}

This handles when a new connection is started by adding that connection to a group. The OnDisconnectedAsync method removes a connection from its group when the connection is terminated both expectedly and unexpectedly. The BroadcastText method handles text-based communication between the clients by sending text that it receives to all other clients in the same group.

Now, register your TextHub in the Startup class by adding the SignalR service and specifying the route that SignalR will use in your app configuration:

public void ConfigureServices(IServiceCollection services)
{
    // Your other services including MVC
    services.AddSignalR();
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    // Your other app configurations including routing.
    app.UseSignalR(routes =>
    {
        routes.MapHub<TextHub>("/texthub");
    });
}

Now the server part is ready for clients to connect to SignalR. The next step is to prepare the client. To get the required packages, you can use npm to install the @ASPNET/SIGNALR package, which contains a JavaScript file, so that you don't need JQuery to make it work. Move the script node_modules\@aspnet\signalr\dist\browser\signalr.min.js to your lib folder. We will reference this in the following code. We also create a JavaScript file called texteditor.js for our own client script. We can use the existing index page, by inserting the following code:

@page
@{
    ViewData["Title"] = "Collaborative Text Editor";
}

@section Scripts
{
    <script src="@Url.Content("~/lib/signalr/signalr.min.js")"></script>
    <script src="@Url.Content("~/js/texteditor.js")"></script>
}

<div>
    <br />
    <textarea style="width:100%;height:300px;" id="editor" onkeyup="change()"></textarea>
</div>

Add the following code to the texteditor.js file.

var editor = document.getElementById("editor");

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/texthub")
    .build();
connection.start().catch(err => console.error(err));

connection.on("ReceiveText", (text) => {
    editor.value = text;
    editor.focus();
    editor.setSelectionRange(editor.value.length,editor.value.length);
});

function change() {
    connection.invoke("BroadcastText", editor.value).catch(err => console.error(err));
}

The first piece of code defines our editor's textarea. We then create a new connection using the HubConnectionBuilder, which is a new feature in this version of SignalR. The URL specified is the same as we made in the Startup class, and it may be necessary to specify the full URL, if you want to host this on a subsite (or with other special conditions that apply). In most cases though, this should be fine.

Next up, we will make a handler that manages when the connection receives a "ReceiveText", being the call we made on the server side when someone wants to broadcast their text to all clients. This also refocuses your cursor to the end of the editor, which makes collaborative writing a lot smoother. The counterpart to this, the change function, invokes the BroadcastText method that we specified on the server. You could make your own methods on the server like this and you would then be able to call them from here in the same way or with another trigger. The trigger for our use is the onkeyup action on the textarea, which means that it makes a broadcast every time you type a character into the editor.

You now have the first functioning version of the editor, and you can try it out by opening two tabs with your site on and observe how it synchronizes any text written in it between the tabs.

We now want to expand on this concept by adding dynamic groups. We first go to the server side again and add a JoinGroup function and change around how groups are handled:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace CollabTextEditor
{
    public class TextHub : Hub
    {
        private static Dictionary<string, string> connectionsNgroup = new Dictionary<string, string>();

        public override async Task OnConnectedAsync()
        {
            await base.OnConnectedAsync();
        }

        public override async Task OnDisconnectedAsync(Exception exception)
        {
            if (connectionsNgroup.ContainsKey(Context.ConnectionId))
            {
                await Groups.RemoveFromGroupAsync(Context.ConnectionId, connectionsNgroup[Context.ConnectionId]);
                connectionsNgroup.Remove(Context.ConnectionId);
            }
            await base.OnDisconnectedAsync(exception);
        }

        public async Task BroadcastText(string text)
        {
            if (connectionsNgroup.ContainsKey(Context.ConnectionId))
            {
                await Clients.OthersInGroup(connectionsNgroup[Context.ConnectionId]).SendAsync("ReceiveText", text);
            }
        }

        public async Task JoinGroup(string group)
        {
            if (connectionsNgroup.ContainsKey(Context.ConnectionId))
            {
                await Groups.RemoveFromGroupAsync(Context.ConnectionId, connectionsNgroup[Context.ConnectionId]);
                connectionsNgroup.Remove(Context.ConnectionId);
            }
            connectionsNgroup.Add(Context.ConnectionId, group);
            await Groups.AddToGroupAsync(Context.ConnectionId, group);
        }
    }
}

The new JoinGroup takes a group as parameter and checks for an existing membership of another group through the dictionary defined first in the class (which maps from connections to groups). This is necessary, because SignalR doesn't provide us with any way of viewing which connections there are in each group.

Navigate back to the index page. Here we will need to add an input field and a button which a user can use to join a group.

// Previous implementation above
<div>
    <br />
    <input id="group" />
    <input type="button" value="Join Group" onclick="join()" />
    <br /><br />
    <textarea style="width:100%;height:300px;" id="editor" onkeyup="change()"></textarea>
</div>

Then implement the join function in the texteditor.js file and make sure that the user can't start typing in the editor if he/she didn't join a group yet.

// Previous implementation above
editor.style.display = "none";
var group = document.getElementById("group");
function join() {
    connection.invoke("JoinGroup", group.value).catch(err => console.error(err));
    editor.style.display = "initial";
}

The collaborative group-based text editor is now done, but we could of course make improvements to it. Examples could be error handling, so that it tries to reconnect if the client loses connection. Better cursor handling, so that multiple people could type at once without getting jumped around or a simple one could be that you get updated with the other's current text when you first join. I leave these enhancements to you for exploration.

Desktop-2018.06.07---14.47.15.10-6-12-1528375736528.1



This post was brought to you by the elmah.io team. elmah.io is the best error management system for .NET web applications. We monitor your website, alert you when errors start happening and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website