Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SignalR: notifying progress of lengthy operation from ASP.NET Core web API to Angular 7 client

EDITED: see at the bottom

I'm new to SignalR and trying to implement with it a simple scenario with Angular7 client using this library, and ASP.NET Core web API. All what I need is to use SignalR to notify the client about the progress of some lengthy operations in methods of the API controllers.

After a number of attempts, I got to a point where apparently the connection is established, but then when the long task starts running and sending messages, my client does not seem to receive anything, nor any traffic appears in web sockets (Chrome F12 - Network - WS).

I post here the details, which might also be useful to other newcomers (full source code at https://1drv.ms/u/s!AsHCfliT740PkZh4cHY3r7I8f-VQiQ). Probably I'm just making some obvious error, yet in the docs and googling around I could not find a code fragment essentially different from mine. Could anyone give a hint?

My start point for the server side was https://msdn.microsoft.com/en-us/magazine/mt846469.aspx, plus the docs at https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-2.2. I tried to create a dummy experimental solution with that.

My code snippets in form of a recipe follow.

(A) Server Side

1.create a new ASP.NET core web API app. No authentication or Docker, just to keep it minimal.

2.add the NuGet package Microsoft.AspNetCore.SignalR.

3.at Startup.cs, ConfigureServices:

public void ConfigureServices(IServiceCollection services)
{
    // CORS
    services.AddCors(o => o.AddPolicy("CorsPolicy", builder =>
    {
        builder.AllowAnyMethod()
            .AllowAnyHeader()
            // https://github.com/aspnet/SignalR/issues/2110 for AllowCredentials
            .AllowCredentials()
            .WithOrigins("http://localhost:4200");
    }));
    // SignalR
    services.AddSignalR();
    
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

and the corresponding Configure method:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseHsts();
    }
    
    // CORS
    app.UseCors("CorsPolicy");
    // SignalR: add to the API at route "/progress"
    app.UseSignalR(routes =>
    {
        routes.MapHub<ProgressHub>("/progress");
    });
    
    app.UseHttpsRedirection();
    app.UseMvc();
}

4.add a ProgressHub class, which just derives from Hub:

public class ProgressHub : Hub
{
}

5.add a TaskController with a method to start some lengthy operation:

[Route("api/task")]
[ApiController]
public class TaskController : ControllerBase
{
    private readonly IHubContext<ProgressHub> _progressHubContext;
    
    public TaskController(IHubContext<ProgressHub> progressHubContext)
    {
        _progressHubContext = progressHubContext;
    }
    
    [HttpGet("lengthy")]
    public async Task<IActionResult> Lengthy([Bind(Prefix = "id")] string connectionId)
    {
        await _progressHubContext
            .Clients
            .Client(connectionId)
            .SendAsync("taskStarted");
            
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(500);
            Debug.WriteLine($"progress={i}");
            await _progressHubContext
                .Clients
                .Client(connectionId)
                .SendAsync("taskProgressChanged", i);
        }
        
        await _progressHubContext
            .Clients
            .Client(connectionId)
            .SendAsync("taskEnded");
            
        return Ok();
    }
}

(B) Client Side

1.create a new Angular7 CLI app (without routing, just to keep it simple).

2.npm install @aspnet/signalr --save.

3.my app.component code:

import { Component, OnInit } from '@angular/core';
import { HubConnectionBuilder, HubConnection, LogLevel } from '@aspnet/signalr';
import { TaskService } from './services/task.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  private _connection: HubConnection;
  
  public messages: string[];
  
  constructor(private _taskService: TaskService) {
    this.messages = [];
  }
  
  ngOnInit(): void {
    // https://codingblast.com/asp-net-core-signalr-chat-angular/
    this._connection = new HubConnectionBuilder()
      .configureLogging(LogLevel.Debug)
      .withUrl("http://localhost:44348/signalr/progress")
      .build();
      
    this._connection.on("taskStarted", data => {
      console.log(data);
    });
    this._connection.on("taskProgressChanged", data => {
      console.log(data);
      this.messages.push(data);
    });
    this._connection.on("taskEnded", data => {
      console.log(data);
    });
    
    this._connection
      .start()
      .then(() => console.log('Connection started!'))
      .catch(err => console.error('Error while establishing connection: ' + err));
  }
  
  public startJob() {
    this.messages = [];
    this._taskService.startJob('zeus').subscribe(
      () => {
        console.log('Started');
      },
      error => {
        console.error(error);
      }
    );
  }
}

Its minimalist HTML template:

<h2>Test</h2>
<button type="button" (click)="startJob()">start</button>
<div>
  <p *ngFor="let m of messages">{{m}}</p>
</div>

The task service in the above code is just a wrapper for a function which calls HttpClient's get<any>('https://localhost:44348/api/task/lengthy?id=' + id).


EDIT 1

After some more experimenting, I came with these changes:

  • use .withUrl('https://localhost:44348/progress') as suggested. It seems that now it no more triggers 404. Note the change: I replaced http with https.

  • do not make the API method async as it seems that the await are not required (i.e. set the return type to IActionResult and remove async and await).

With these changes, I can now see the expected log messages on the client side (Chrome F12). Looking at them, it seems that the connection gets bound to a generated ID k2Swgcy31gjumKtTWSlMLw:

Utils.js:214 [2019-02-28T20:11:48.978Z] Debug: Starting HubConnection.
Utils.js:214 [2019-02-28T20:11:48.987Z] Debug: Starting connection with transfer format 'Text'.
Utils.js:214 [2019-02-28T20:11:48.988Z] Debug: Sending negotiation request: https://localhost:44348/progress/negotiate.
core.js:16828 Angular is running in the development mode. Call enableProdMode() to enable the production mode.
Utils.js:214 [2019-02-28T20:11:49.237Z] Debug: Selecting transport 'WebSockets'.
Utils.js:210 [2019-02-28T20:11:49.377Z] Information: WebSocket connected to wss://localhost:44348/progress?id=k2Swgcy31gjumKtTWSlMLw.
Utils.js:214 [2019-02-28T20:11:49.378Z] Debug: Sending handshake request.
Utils.js:210 [2019-02-28T20:11:49.380Z] Information: Using HubProtocol 'json'.
Utils.js:214 [2019-02-28T20:11:49.533Z] Debug: Server handshake complete.
app.component.ts:39 Connection started!
app.component.ts:47 Task service succeeded

So, it might be the case that I get no notification because my client ID does not match the ID assigned by SignalR (from the paper quoted above I had the impression that it was my duty to provide an ID, given that it is an argument of the API controller). Yet, I cannot see any available method or property in the connection prototype allowing me to retrieve this ID, so that I can pass it to the server when launching the lengthy job. Could this be the reason of my issue? If this is so, there should be a way of getting the ID (or setting it from the client side). What do you think?

like image 564
Naftis Avatar asked Feb 28 '19 13:02

Naftis


2 Answers

It seems I've finally found it. The issue was probably caused by the wrong ID, so I started looking for a solution. A post (https://github.com/aspnet/SignalR/issues/2200) guided me to the usage of groups, which seems the recommended solution in these cases. So, I changed my hub so that it automatically assign the current connection ID to a "progress" group:

public sealed class ProgressHub : Hub
{
    public const string GROUP_NAME = "progress";

    public override Task OnConnectedAsync()
    {
        // https://github.com/aspnet/SignalR/issues/2200
        // https://learn.microsoft.com/en-us/aspnet/signalr/overview/guide-to-the-api/working-with-groups
        return Groups.AddToGroupAsync(Context.ConnectionId, "progress");
    }
}

Now, my API controller method is:

[HttpGet("lengthy")]
public async Task<IActionResult> Lengthy()
{
    await _progressHubContext
        .Clients
        .Group(ProgressHub.GROUP_NAME)
        .SendAsync("taskStarted");
    for (int i = 0; i < 100; i++)
    {
        Thread.Sleep(200);
        Debug.WriteLine($"progress={i + 1}");
        await _progressHubContext
            .Clients
            .Group(ProgressHub.GROUP_NAME)
            .SendAsync("taskProgressChanged", i + 1);
    }
    await _progressHubContext
        .Clients
        .Group(ProgressHub.GROUP_NAME)
        .SendAsync("taskEnded");

    return Ok();
}

And of course I updated the client code accordingly, so that it does no more have to send an ID when invoking the API method.

Full demo repository available at https://github.com/Myrmex/signalr-notify-progress.

like image 68
Naftis Avatar answered Nov 15 '22 16:11

Naftis


You set the route for the hub as /progress, but then you're attempting to connect to /signalr/progress, which is going to be a 404. If you open the developer console, you should have an connection error there telling you as much.

like image 1
Chris Pratt Avatar answered Nov 15 '22 17:11

Chris Pratt