Last time in part 3 of the series, we looked at the Function App sitting in the middle of the architecture. This time, we will look at how the Function App and front-end communicate in real-time. Specifically, this part of the architecture:

Architecture diagram with Function App, SignalR Service and front-end

SignalR Service

Since we run on a serverless back-end, we use Azure's SignalR Service as a back-end that clients connect to. This way our Function App can scale to zero and the Websocket connections stay alive.

Using the service also allows us to scale out the number of Websocket connections and bandwidth that we need separately from our back-end.

We specifically run SignalR Service in serverless mode due to using a serverless back-end. This means the connection between our back-end and SignalR Service is not real-time. Only the connections between clients and SignalR Service.

Starting the connection

When the front-end loads, it starts the SignalR connection through the Typescript client:

export const connection = new HubConnectionBuilder()
  .withUrl("/api/signalr")
  .withAutomaticReconnect()
  .build();

export function startConnection() {
  if (connection.state !== "Disconnected") {
    return;
  }

  connection
    .start()
    .then(function () {
      console.info("SignalR connected");
    })
    .catch(function (reason) {
      console.error(reason);
      setTimeout(startConnection, 5000);
    });
}

Note that we connect to the Function App, so where is the SignalR Service then? We have a Function that the client connects to, which redirects the connection to Azure SignalR:

[Function("SignalRNegotiate")]
public string Negotiate(
    [HttpTrigger(AuthorizationLevel.Anonymous, Route = "api/signalr/negotiate")] HttpRequestData req,
    [SignalRConnectionInfoInput(HubName = "%AzureSignalRHubName%", ConnectionStringSetting = "AzureSignalRConnectionString")] string connectionInfo)
{
    return connectionInfo;
}

You can find more information on SignalR Service bindings in the documentation.

Sending messages to clients

So now that the client is connected, how do we go about sending a message to them? Let's say that a vehicle's location has updated and we would like to notify all clients about it.

To do that we return a SignalRMessageAction from the Function that processes the update:

[Function(nameof(UpdateLatestLocations))]
[SignalROutput(HubName = "%AzureSignalRHubName%", ConnectionStringSetting = "AzureSignalRConnectionString")]
public async Task<SignalRMessageAction> UpdateLatestLocations(
    [EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup = "latestLocationUpdate")] string[] events,
    FunctionContext functionContext)
{
    // Other stuff
    return new SignalRMessageAction("locationUpdated", new object[]
    {
        trackerId.ToString(),
        ev.Lat,
        ev.Lng,
        new DateTimeOffset(ev.Ts).ToUnixTimeMilliseconds(),
    };
}

If we don't specify a connection/user/group, the message will be broadcast to all clients. The Function does this by calling an API on SignalR Service, which then does the broadcast.

Receiving the message in the front-end requires us to define the matching function:

function onLocationUpdated(trackerId: string, latitude: number, longitude: number, timestamp: number) {
    // Implementation
}

connection.on("locationUpdated", onLocationUpdated);

Note this is yet another time when it is very easy to put longitude and latitude the wrong way around.

Sending message from client to back-end

Websockets allow for two-way communication, so we can also send real-time messages from the client. In the front-end this is pretty simple:

connection.invoke("updateMapGridGroups", newGridSquares, previousGridSquares);

Getting this to reach our serverless back-end however, is a bit more complicated. Since we run SignalR service in Serverless mode, it will send these messages through webhooks (aka upstreams).

The deployment script in the sample app configures these upstreams automatically through Az CLI:

$prodHubName = $config.prodSignalRHubName.ToLower()
$devHubName = $config.devSignalRHubName.ToLower()

$functionsAppKey = az functionapp keys list --subscription "$subscriptionId" -g "$resourceGroup" -n "$functionsAppName" --query "systemKeys.signalr_extension" -o tsv
if ($LASTEXITCODE -ne 0) {
    throw "Failed to get Functions App key for SignalR extension"
}

if (-not $functionsAppKey) {
    Write-Host "Functions App key for SignalR extension not found (content not yet deployed?). Removing SignalR service production upstream URL."
    $updatedProdUpstreamUrl = ""
}
else {
    $updatedProdUpstreamUrl = "https://$functionsAppHostName/runtime/webhooks/signalr?code=$functionsAppKey"
}

if ($currentDevUpstreamUrl -and $updatedProdUpstreamUrl) {
    # Set both dev and prod upstream URLs
    az signalr upstream update --subscription "$subscriptionId" -g "$resourceGroup" -n "$signalRName" `
        --template url-template="$updatedProdUpstreamUrl" hub-pattern="$prodHubName" category-pattern="*" event-pattern="*" `
        --template url-template="$currentDevUpstreamUrl" hub-pattern="$devHubName" category-pattern="*" event-pattern="*" | Out-Null
}
elseif (-not $currentDevUpstreamUrl -and $updatedProdUpstreamUrl) {
    # Only set prod upstream URL
    az signalr upstream update --subscription "$subscriptionId" -g "$resourceGroup" -n "$signalRName" `
        --template url-template="$updatedProdUpstreamUrl" hub-pattern="$prodHubName" category-pattern="*" event-pattern="*" | Out-Null
}
elseif ($currentDevUpstreamUrl -and -not $updatedProdUpstreamUrl) {
    # Only set dev upstream URL
    az signalr upstream update --subscription "$subscriptionId" -g "$resourceGroup" -n "$signalRName" `
        --template url-template="$currentDevUpstreamUrl" hub-pattern="$devHubName" category-pattern="*" event-pattern="*" | Out-Null
}
else {
    az signalr upstream clear --subscription "$subscriptionId" -g "$resourceGroup" -n "$signalRName" | Out-Null
}

if ($LASTEXITCODE -ne 0) {
    throw "Failed to set SignalR service upstream URLs."
}

Note that we set two upstreams (if available), one for local development and one for Azure. How does SignalR Service know which URL to send the message to? With the hub-pattern. It checks which hub the message is headed to, and sends it to the corresponding URL.

One major trap to note here is that SignalR lowercases hub names. So even if you specify your hub name as "MyHub", you must use "myhub" in the pattern! It took a while to figure out why the messages were not arriving...

The Function that receives the message looks like this:

[Function(nameof(UpdateMapGridGroups))]
[SignalROutput(HubName = "%AzureSignalRHubName%", ConnectionStringSetting = "AzureSignalRConnectionString")]
public List<SignalRGroupAction> UpdateMapGridGroups(
    [SignalRTrigger("%AzureSignalRHubName%", "messages", "updateMapGridGroups", "newGridNumbers", "previousGridNumbers", ConnectionStringSetting = "AzureSignalRConnectionString")] SignalRInvocationContext invocationContext,
    int[][] newGridNumbers,
    int[][] previousGridNumbers,
    FunctionContext functionContext)
{
    // Implementation
}

This uses the trigger binding from the SignalR Service extension. Note that we have to specify the category as "messages", as you can also receive connection updates (client connected/disconnected). Then we define the back-end method name as well as its parameter names. The extension takes care of the rest.

We will discuss what this method is for in a future part where we talk about performance optimizations.

Summary

With this, we now have real-time communication between the front-end client and the Function App back-end. We can send real-time updates on vehicle locations so that users can see where the vehicles are around the globe. Next time we will check the front-end application and how we implement the map view in the sample app.