Azure / azure-webpubsub

Azure Web PubSub Service helps you to manage WebSocket connections and do publish and subscribe in an easy way
https://azure.github.io/azure-webpubsub/
MIT License
135 stars 85 forks source link

Using webpubsub in angular + azure function application #204

Closed nflachaire closed 3 years ago

nflachaire commented 3 years ago

Describe the bug

Cannot send any message from websocket in angular browser application. It closes the socket instead.

To Reproduce

app.component.ts (Angular side)

(all other files are auto generated with the creation of a new project => ng new [project name] )

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  title = 'socket-angular';

  websocket: WebSocket;
  connected: boolean;

  constructor(private _http: HttpClient) {}

  ngOnInit() {

    console.log("Creating body data for Http Request...");
    let data = {
      "hubName":"test"
    }

    console.log("Sending Http Requests...")
    this._http.post<any>("http://localhost:7071/api/getSocketToken", data).subscribe((res) => {

      console.log("Http Request successful, return data is :");
      console.log(res);

      console.log("Creating websocket from ReturnData.tokenUrl");
      this.websocket = new WebSocket(res.tokenUrl);

      this.websocket.onopen = e => {
        this.connected = true;
        console.log(this.websocket);
        console.log("Client websocket opened.");
        let message: string = "test from angular browser";
        console.log("Sending message : " + message)
        this.websocket.send(message);
      }

      this.websocket.onclose = e => {
          this.connected = false;
          console.log("Client websocket closed.");
      }

      this.websocket.onerror = e => {
          console.log("Client websocket error, check the Console window for details.");
      }

      this.websocket.onmessage = e => {
        console.log("Message received")
        if (!e.data) return;
        console.log(e.data);
      }
    });
  }
}

GetSocketToken.cs (Azure Function API, target of the http request)

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Azure.Messaging.WebPubSub;
using Websocket.Client;

namespace WebAppTT_API.Requete.Calcul
{
    public static class GetSocketToken
    {

        public class RequestDataGetSocketToken
        {
            /// <summary>  Nom de l'entreprise </summary>
            public string hubName { get; set; }
        }

        public class ReturnDataGetSocketToken
        {
            /// <summary>  Nom de l'entreprise </summary>
            public string tokenUrl { get; set; }
        }

        [FunctionName("GetSocketToken")]
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            // Initialisation des variables des données en entrée et sortie de la fonction
            RequestDataGetSocketToken requestDataSocket = new RequestDataGetSocketToken();
            ReturnDataGetSocketToken returnDataGetSocketToken = new ReturnDataGetSocketToken();

            Donnees.RetourVerification retourVerificationRecuperationDonnees = Donnees.RecupererDonnees.recupererDonneesBodyAsync(log, req, ref requestDataSocket);
            if (retourVerificationRecuperationDonnees.existeErreur == true) return new BadRequestObjectResult(retourVerificationRecuperationDonnees.messageErreur);

            // Either generate the URL or fetch it from server or fetch a temp one from the portal
            var serviceClient = new WebPubSubServiceClient(Global.ConfigFile.connectionStringWebPubSub, requestDataSocket.hubName);
            returnDataGetSocketToken.tokenUrl = serviceClient.GetClientAccessUri(userId: requestDataSocket.hubName).AbsoluteUri;

            return new OkObjectResult(returnDataGetSocketToken);
        }

    }
}

Log from browser console

app.component.ts:19 Creating body data for Http Request...
app.component.ts:24 Sending Http Requests...
core.js:27988 Angular is running in development mode. Call enableProdMode() to enable production mode.
app.component.ts:27 Http Request successful, return data is :
app.component.ts:28 {tokenUrl: "wss://websockettt.webpubsub.azure.com/client/hubs/…3QifQ.VmPxg1A_hhNPdoXInSm-iLhrQOD6_fuUpJ_jj4OQqCg"}tokenUrl: "wss://websockettt.webpubsub.azure.com/client/hubs/test?access_token=eyJhbGciOiJIUzI1NiIsImtpZCI6IjkwM2M0YzIwYmM4OTRiYmFiNzRkMTcyMmFjNDIxOWQ0IiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNjI3Mzk4MjMxLCJleHAiOjE2Mjc0MDE4MzEsImlhdCI6MTYyNzM5ODIzMSwiYXVkIjoiaHR0cHM6Ly93ZWJzb2NrZXR0dC53ZWJwdWJzdWIuYXp1cmUuY29tL2NsaWVudC9odWJzL3Rlc3QifQ.VmPxg1A_hhNPdoXInSm-iLhrQOD6_fuUpJ_jj4OQqCg"[[Prototype]]: Object
app.component.ts:30 Creating websocket from ReturnData.tokenUrl
client:52 [WDS] Live Reloading enabled.
app.component.ts:36 WebSocket {__zone_symbol__openfalse: Array(1), __zone_symbol__closefalse: Array(1), __zone_symbol__ON_PROPERTYopen: ƒ, __zone_symbol__ON_PROPERTYclose: ƒ, __zone_symbol__ON_PROPERTYerror: ƒ, …}binaryType: "blob"bufferedAmount: 0extensions: ""onclose: (...)onerror: (...)onmessage: (...)onopen: (...)protocol: ""readyState: 3url: "wss://websockettt.webpubsub.azure.com/client/hubs/test?access_token=eyJhbGciOiJIUzI1NiIsImtpZCI6IjkwM2M0YzIwYmM4OTRiYmFiNzRkMTcyMmFjNDIxOWQ0IiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNjI3Mzk4MjMxLCJleHAiOjE2Mjc0MDE4MzEsImlhdCI6MTYyNzM5ODIzMSwiYXVkIjoiaHR0cHM6Ly93ZWJzb2NrZXR0dC53ZWJwdWJzdWIuYXp1cmUuY29tL2NsaWVudC9odWJzL3Rlc3QifQ.VmPxg1A_hhNPdoXInSm-iLhrQOD6_fuUpJ_jj4OQqCg"__zone_symbol__ON_PROPERTYclose: e => {…}__zone_symbol__ON_PROPERTYerror: e => {…}__zone_symbol__ON_PROPERTYmessage: e => {…}__zone_symbol__ON_PROPERTYopen: e => {…}__zone_symbol__closefalse: [ZoneTask]__zone_symbol__errorfalse: [ZoneTask]__zone_symbol__messagefalse: [ZoneTask]__zone_symbol__openfalse: [ZoneTask][[Prototype]]: WebSocket
app.component.ts:37 Client websocket opened.
app.component.ts:39 Sending message : test from angular browser
app.component.ts:45 Client websocket closed.

I should be able to send a message and receive it from the same socket. Either way, no other application receive the message even though they have Websockets connected on the same URL (using same hubname and same line of code to retrive the token).

First of all, is it possible to open a websocket in a browser environment and communicating with websocket in other apps ? If yes, is it possible to do it within the angular framework ? If yes, what am I doing wrong ? Am I using the wrong tools ?

I do not use a long-term server like express.js with my angular application. The goal is to have the Azure functions replace entirely the backend, and it would the best to only have http requests from my angular application to the azure functions.

I have tried with the WebPubSub Triggers within Azure Functions but I still have the same problem. And I don't want to use those if possible.

JialinXin commented 3 years ago

Checked service log and it shows you don't set upstream for the message event.

Microsoft.Azure.SignalR.EventHandlerNotFoundException: Upstream not found for hub:test,event:message,type:user,category:messages.

Please check THIS for introduction about event handler settings.

And to process message event you can use any of below ways:

  1. Client subprotocol: https://azure.github.io/azure-webpubsub/references/pubsub-websocket-subprotocol
  2. Azure function HttpTrigger together with WebPubSubRequest input binding: https://azure.github.io/azure-webpubsub/references/functions-bindings#example---webpubsubrequest.
  3. Azure function WebPubSubTrigger: https://azure.github.io/azure-webpubsub/references/functions-bindings#trigger-binding
nflachaire commented 3 years ago

In the introduction about event handler settings, there is this line of code : app.use(handler.getMiddleware());

I do not have an app object because I'm not using Express.js : const app = express();

How do I use the event handler within my app.component.ts file ?

JialinXin commented 3 years ago

The sample you looked is self-host server in js version, where it'll create the app. Similarly in function, you can switch to js version function and with js version SDK to new the app. However, server SDK C# version middleware is not available yet. So you have to parse the request yourself. Check this sample for C# version with self-host server: https://azure.github.io/azure-webpubsub/getting-started/create-a-chat-app/csharp-handle-events#handle-events.

And move this in function is quite similar which means you need to read the http request yourself, then with output binding to do what you want, e.g. broadcast to all or send to specific user/group.

If your scenario is quite simple and you don't want to own server logic to handle this, check sub-protocol C# sample here: https://azure.github.io/azure-webpubsub/getting-started/using-pubsub-subprotocol/csharp-work-with-subprotocols. Provided as Option1. And notice that subprotocol doesn't support broadcast all and you need to add client required claims.

And if you do have more complicated needs and preferred to use HttpTrigger, you can leverage WebPubSubRequest input binding which we've helped pre-process the request under our protocols. Provided as Option2.

And just reminder that besides to add function, event handler settings should be configured in your Web PubSub service, like from Azure Portal -> Settings.

nflachaire commented 3 years ago

Sorry for the delayed response.

Based on your help, I made some progress. Using the client subprotocol, I can now receive on my browser client the messages send by others external websocket (that are not using the subprotocol). But when I send a message from my browser client to the external websocket, the external websocket does not receive it.

Do I need to use the subprotocol from the external socket side to receive information from the browser client websocket ? I want to use the more simple websocket possible. I do not need to specifiy any meta data when sending/receiving information. I only need to send a string (serialized json). I used the subprotocol on the browser client side only to establish the upstream connection needed.

I can work around that problem by using an azure function to publish data on the websocket stream from the browser client. But I don't like using an middleman here.

Here are the code from the browser client and external websocket (in c#)

Browser Client Angular Application

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  title = 'socket-angular';

  websocket: WebSocket;
  connected: boolean;

  constructor(private _http: HttpClient) {}

  ngOnInit() {

    console.log("Creating body data for Http Request...");
    let data = {
      "hubName":"pocsocket"
    }

    console.log("Sending Http Requests...")
    this._http.post<any>("http://localhost:7071/api/getSocketToken", data).subscribe((res) => {

      console.log("Http Request successful, return data is :");
      console.log(res);

      console.log("Creating websocket from ReturnData.tokenUrl");
      this.websocket = new WebSocket(res.tokenUrl, 'json.webpubsub.azure.v1');

      this.websocket.onopen = e => {
        this.connected = true;
        console.log(this.websocket);
        console.log("Client websocket opened.");
        //let message: string = "test from angular browser";
        let message = JSON.stringify({
          type: 'sendToGroup',
          group: 'stream',
          dataType: 'text',
          data: data.toString()
        });
        console.log("Sending message : " + message)
        this.websocket.send(message);
      }

      this.websocket.onclose = e => {
          this.connected = false;
          console.log("Client websocket closed.");
      }

      this.websocket.onerror = e => {
          console.log("Client websocket error, check the Console window for details.");
      }

      this.websocket.onmessage = e => {
        console.log("Message received")
        if (!e.data) return;
        console.log(e.data);
      }
    });
  }
}

External Websocket from c# Application

using Azure.Messaging.WebPubSub;
using Websocket.Client;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using System.Net.WebSockets;

namespace Opto22DataManager
{
    public class WebSocket
    {
        Config config;
        string connectionString = "<Connection-String>";
        string hubName = "pocsocket";
        WebPubSubServiceClient webPubSubServiceClient;

        public WebSocket(Config config)
        {
            this.config = config;
            this.webPubSubServiceClient = new WebPubSubServiceClient(this.connectionString, this.hubName);
        }

        public void sendMessageToAll(string message)
        {
            this.webPubSubServiceClient.SendToAll(message);
        }

        public async Task onMessage()
        {
            this.webPubSubServiceClient.SendToAll(JsonConvert.SerializeObject(new DataTypes.Log("onMessage Ready")));
            using (var client = new WebsocketClient(webPubSubServiceClient.GetClientAccessUri()))
            {
                client.MessageReceived.Subscribe(message =>
                {
                    Console.WriteLine($"Message received: {message}");
                    if (config.recuperationConfigProjet == true)
                    {
                        var data = JsonConvert.DeserializeObject<object>(message.Text);
                    }
                });

                await client.Start();
                Console.Read();
            }
        }

        public async Task onMessageWithSubprotocol()
        {
            using (var client = new WebsocketClient(webPubSubServiceClient.GetClientAccessUri(userId: config.macAdress, roles: new string[] { "webpubsub.joinLeaveGroup.demogroup", "webpubsub.sendToGroup.demogroup" }), () =>
            {
                var inner = new ClientWebSocket();
                inner.Options.AddSubProtocol("json.webpubsub.azure.v1");
                return inner;
            }))
            {
                // Disable the auto disconnect and reconnect because the sample would like the client to stay online even no data comes in
                client.ReconnectTimeout = null;
                client.MessageReceived.Subscribe(msg => Console.WriteLine($"Message received: {msg}"));
                await client.Start();
                Console.WriteLine("Connected.");
                client.Send(JsonConvert.SerializeObject(new
                {
                    type = "joinGroup",
                    group = "demogroup",
                    ackId = 1
                }));
                Console.Read();
            }
        }
    }
}

As you can see in the C# snippet. I tried with the sample code for subprotocol in C# in the function onMessageWithSubprotocol but I get this error : Message received: {"type":"ack","ackId":1,"success":false,"error":{"name":"Forbidden","message":"The client does not have permission to join group 'demogroup'."}}

It seems I need to create a group to be able to join it. But I do not find how to do it.

Also, an another question. I see there is an Client Access URL. What is the purpose of this URL ? Can I use it in my situation to simplify the connections with subprotocol ?

JialinXin commented 3 years ago

It's because you need to add client claims to allow them using subprotocol, see intro. In normal cases, clients are not trustful, and server should do something to authenticate then add permissions. But limited to function side input binding process order, the input binding is built already when arrived in function, and function side will not have chance to do real check during negotiate. So it's suggested to add claims during connect event. See connection establish procedure and sample code below.

image

public static ServiceResponse Connect(
    [WebPubSubTrigger(this.hubName, WebPubSubEventType.System, "connect")] ConnectionContext connectionContext)
{
    Console.WriteLine($"Received client connect with connectionId: {connectionContext.ConnectionId}");
    // Do some user checks
    if (connectionContext.UserId == "attacker")
    {
        return new ErrorResponse(WebPubSubErrorCode.Unauthorized);
    }
    return new ConnectResponse
    {
        UserId = connectionContext.UserId,
        Claims = new string[] {"webpubsub.joinLeaveGroup.demogroup"}
    };
}

Or you may need to leverage server SDK to build valid tokens directly in negotiate function after some checks. For example:

...
var serviceClient = new WebPubSubServiceClient(connectionString, hub);
if (userId == "attacker")
{
    return new HttpResponseMessage(HttpStatusCode.Unauthorized);
}
var url = serviceClient.GenerateClientAccessUri(userId: "user1", roles: new string[] {"webpubsub.joinLeaveGroup.demogroup"});
...
nflachaire commented 3 years ago

I didn't thought that the problem would come from the Azure Function generating the url.

My previous version was that :

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Azure.Messaging.WebPubSub;
using Websocket.Client;

namespace WebAppTT_API.Requete.Calcul
{
    public static class GetSocketToken
    {

        public class RequestDataGetSocketToken
        {
            public string hubName;
            public string userId;
        }

        public class ReturnDataGetSocketToken
        {
            public string tokenUrl { get; set; }
        }

        [FunctionName("GetSocketToken")]
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            // Initialisation des variables des données en entrée et sortie de la fonction
            RequestDataGetSocketToken requestDataSocket = new RequestDataGetSocketToken();
            ReturnDataGetSocketToken returnDataGetSocketToken = new ReturnDataGetSocketToken();

            Donnees.RetourVerification retourVerificationRecuperationDonnees = Donnees.RecupererDonnees.recupererDonneesBodyAsync(log, req, ref requestDataSocket);
            if (retourVerificationRecuperationDonnees.existeErreur == true) return new BadRequestObjectResult(retourVerificationRecuperationDonnees.messageErreur);

            // Either generate the URL or fetch it from server or fetch a temp one from the portal
            var serviceClient = new WebPubSubServiceClient(Global.ConfigFile.connectionStringWebPubSub, requestDataSocket.hubName);
            returnDataGetSocketToken.tokenUrl = serviceClient.GenerateClientAccessUri(userId: requestDataSocket.userId, roles: new string[] { "webpubsub.joinLeaveGroup.demogroup", "webpubsub.sendToGroup.demogroup" }).AbsoluteUri;

            return new OkObjectResult(returnDataGetSocketToken);
        }

    }
}

Modifying the line :

returnDataGetSocketToken.tokenUrl = serviceClient.GenerateClientAccessUri(userId: requestDataSocket.userId, roles: new string[] { "webpubsub.joinLeaveGroup.demogroup" }).AbsoluteUri;

To :

returnDataGetSocketToken.tokenUrl = serviceClient.GenerateClientAccessUri(userId: requestDataSocket.userId, roles: new string[] { "webpubsub.joinLeaveGroup.demogroup", "webpubsub.sendToGroup.demogroup" }).AbsoluteUri;

Solved my problem. I was just missing the "webpubsub.sendToGroup.demogroup".

I can now send and receive messages from either side.

Thanks a lot for the help 👍

JialinXin commented 3 years ago

Good to know. Thanks for using our service and feel free to share your needs and comments!