Search code examples
javascriptc#reactjsazure-functionsazure-signalr

How to connect React to SignalR with Azure Function Locally


Following along with the Azure SignalR Service Serverless Quick Start tutorial I was able to run the hosted html file just fine while also pinging the negotiate function from postman or a browser works as well. The problem I'm having is getting a React JS application to do the exact same thing. Instead of connecting to SignalR like the html's JS block, I get this error:

HttpConnection.ts:350  Uncaught (in promise) Error: Failed to complete negotiation with the server: TypeError: Failed to fetch
    at HttpConnection._getNegotiationResponse (HttpConnection.ts:350:35)
    at async HttpConnection._startInternal (HttpConnection.ts:246:41)
    at async HttpConnection.start (HttpConnection.ts:136:9)
    at async _HubConnection._startInternal (HubConnection.ts:228:9)
    at async _HubConnection._startWithStateTransitions (HubConnection.ts:202:13)

I understand that the fetch is failing, but I don't understand why. There shouldn't be a CORS problem since Postman and the Browser can access it just fine, however, I'm at a loss for what else could be wrong since the example index.html file works fine, but not the React one.

Here's the Azure Function:

using System;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;

namespace csharp_isolated;

public class Functions
{
    private static readonly HttpClient HttpClient = new();
    private static string Etag = string.Empty;
    private static int StarCount = 0;

    [Function("index")]
    public static HttpResponseData GetHomePage([HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequestData req)
    {
        var response = req.CreateResponse(HttpStatusCode.OK);
        response.WriteString(File.ReadAllText("content/index.html"));
        response.Headers.Add("Content-Type", "text/html");
        return response;
    }

    [Function("negotiate")]
    public static HttpResponseData Negotiate([HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequestData req,
        [SignalRConnectionInfoInput(HubName = "serverless")] string connectionInfo)
    {
        var response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "application/json");
        response.WriteString(connectionInfo);
        return response;
    }

    [Function("broadcast")]
    [SignalROutput(HubName = "serverless")]
    public static async Task<SignalRMessageAction> Broadcast([TimerTrigger("*/5 * * * * *")] TimerInfo timerInfo)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/azure/azure-signalr");
        request.Headers.UserAgent.ParseAdd("Serverless");
        request.Headers.Add("If-None-Match", Etag);
        var response = await HttpClient.SendAsync(request);
        if (response.Headers.Contains("Etag"))
        {
            Etag = response.Headers.GetValues("Etag").First();
        }
        if (response.StatusCode == HttpStatusCode.OK)
        {
            var result = await response.Content.ReadFromJsonAsync<GitResult>();
            if (result != null)
            {
                StarCount = result.StarCount;
            }
        }
        int randomNum = Random.Shared.Next(1, 10); // helps to visualize that the messages are going through
        return new SignalRMessageAction("newMessage", [$"Current star count of https://github.com/Azure/azure-signalr is: {StarCount + randomNum}"]);
    }

    private class GitResult
    {
        [JsonPropertyName("stargazers_count")]
        public int StarCount { get; set; }
    }
}

and here is the React app (using TypeScript + Vite)

import * as React from 'react';
import './App.css';
import * as signalR from "@microsoft/signalr";

const URL = "http://localhost:7071/api";
const list: string[] = [];

interface MessageProps {
  HubConnection: signalR.HubConnection
}

const Messages: React.FC<MessageProps> = (props: MessageProps) => {
  const { HubConnection } = props;
  React.useEffect(() => {
    HubConnection.on("newMessage", message => list.push(message));
  }, []);

  return <>{list.map((message, index) => <p key={index}>{message}</p>)}</>
}

const App: React.FC = () => {
  const hubConnection = new signalR.HubConnectionBuilder()
    .withUrl(URL)
    .configureLogging(signalR.LogLevel.Information)
    .build();

  hubConnection.start()
    .catch(console.error);

  return <Messages HubConnection={hubConnection} />
}

export default App;

What really confuses me is how the JS in the html file works just fine:

<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/3.1.7/signalr.min.js"></script>
<script>
    let messages = document.querySelector('#messages');
    const apiBaseUrl = window.location.origin;
    console.log('base url:', apiBaseUrl);
    const connection = new signalR.HubConnectionBuilder()
        .withUrl(apiBaseUrl + '/api')
        .configureLogging(signalR.LogLevel.Information)
        .build();
    connection.on('newMessage', (message) => {
        document.getElementById("messages").innerHTML = message;
    });

    connection.start()
        .catch(console.error);
</script>

but what looks like essentially the same thing in React is DOA due to a fetch failure from the SignalR JS.

The only thing I can figure is that there is in fact a CORS problem from running the react app on localhost:5173 and the Azure Function on localhost:7071, but the browser and Postman can each hit the negotiate route without any access problems, it's just the react app that fails.

network tab output from React page:

Request URL:        http://localhost:7071/api/negotiate?negotiateVersion=1
Referrer Policy:    strict-origin-when-cross-origin

-- Edit --

I should also point out that the local.settings.json file contains:

{
...
  "Host": {
      "LocalHttpPort": 7071,
      "CORS": "*",
      "CORSCredentials": false
  },
...
}

Ok, so it is a CORS issue, now I'm seeing:

localhost/:1  Access to fetch at 'http://localhost:7071/api/negotiate?negotiateVersion=1' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Also tried to specify the urls in the host settings to address the above * comment:

localhost/:1  Access to fetch at 'http://localhost:7071/api/negotiate?negotiateVersion=1' from origin 'http://localhost:5123' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Even with host set to * or specific URLs CORS is still saying no. How exactly is one supposed to set CORS for a local setup when local.settings.json is not enough?


Solution

  • This Worked for me.

    I created a SignalR Function for negotiation.

    Function1.cs:

    using System.Collections.Generic;
    using System.Net;
    using Microsoft.Azure.Functions.Worker;
    using Microsoft.Azure.Functions.Worker.Http;
    using Microsoft.Extensions.Logging;
    
    namespace FunctionApp7
    {
        public class Function
        {
            private readonly ILogger _logger;
    
            public Function(ILoggerFactory loggerFactory)
            {
                _logger = loggerFactory.CreateLogger("negotiate");
            }
    
            [Function("negotiate")]
            public async Task<HttpResponseData> Negotiate(
                [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
                [SignalRConnectionInfoInput(HubName = "serverless")] MyConnectionInfo connectionInfo)
            {
                _logger.LogInformation($"SignalR Connection URL = '{connectionInfo.Url}'");
    
                var response = req.CreateResponse(HttpStatusCode.OK);
                response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
                response.WriteStringAsync($"Connection URL = '{connectionInfo.Url}'");
    
                return response;
            }
        }
    
        public class MyConnectionInfo
        {
            public string Url { get; set; }
    
            public string AccessToken { get; set; }
        }
    }
    

    I created a Vite React app using command npm create vite@Latest react_app used typescripttemplate.

    And i added code in my App.tsx file

    App.tsx:

    import { useState, useEffect, useRef } from 'react';
    import { HubConnectionBuilder } from '@microsoft/signalr';
    import reactLogo from './assets/react.svg';
    import viteLogo from "./vite.svg";
    import './App.css';
    
    function App() {
      const [count, setCount] = useState(0);
      const hubConnection = useRef<any>(null); // Ref to hold the SignalR connection instance
    
      useEffect(() => {
        const startSignalRConnection = async () => {
          try {
            const hubUrl = 'http://localhost:7146/api/negotiate';
            const response = await fetch(hubUrl, { method: 'POST' });
            const connectionInfo = await response.json();
    
            const connection = new HubConnectionBuilder()
              .withUrl(connectionInfo.url, {
                accessTokenFactory: () => connectionInfo.accessToken,
              })
              .withAutomaticReconnect()
              .build();
    
            connection.start();
    
            connection.on('someEvent', (data) => {
              console.log('Received data from SignalR:', data);
            });
    
            hubConnection.current = connection; // Assign the connection instance to the ref
          } catch (error) {
            console.error('Error setting up SignalR connection:', error);
          }
        };
    
        startSignalRConnection();
    
        return () => {
          if (hubConnection.current) {
            hubConnection.current.off('someEvent'); // Remove event listener
            hubConnection.current.stop(); // Stop the SignalR connection
          }
        };
      }, []);
    
      return (
        <>
          <div>
            <a href="https://vitejs.dev" target="_blank">
              <img src={viteLogo} className="logo" alt="Vite logo" />
            </a>
            <a href="https://react.dev" target="_blank">
              <img src={reactLogo} className="logo react" alt="React logo" />
            </a>
          </div>
          <h1>Vite + React</h1>
          <div className="card">
            <p>
              Edit <code>src/App.tsx</code> and save to test HMR
            </p>
          </div>
          <p className="read-the-docs">Click on the Vite and React logos to learn more</p>
        </>
      );
    }
    
    export default App;
    
    

    OUTPUT: