Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Javascript

gRPC, .NET 8.0 and Kestrel in Easy Samples

5.00/5 (11 votes)
8 May 2024MIT22 min read 11.7K  
I demonstrate the samples adding gRPC capabilities to Kestrel ASP.NET server and testing gRPC services with various clients.
This article demonstrates running gRPC functionality within ASP.NET Kestrel server and using various gRPC clients to access it. In particular, I describe using a multiplatform C# Console client, HTML/JavaScript in-browser client and non-Blazor WebAssembly C# client. The examples cover all gRPC paradigms including Single Request/Single Response, Single Request/Server Streaming, Client Streaming/Single Response and bi-directional Streaming.

Web gRPC Under ASP.NET 8.0, Kestrel with HTML/JavaScript and Avalonia Clients

Image 1Image 2

Dedication

This dedication is my farewell present to my good friends at Kahua.com including

  • Colin Whitlock - who on top of being a great CTO, is also a great manager, architect and developer in the company.
  • Jon Sinsel - an extremely sharp C# guru who very patiently introduced me to Uno Platform and Kahua code.
  • Adam Love - the best Web, Mobile wizard (also a great C# developer) without whose help, I'd still be figuring out things about deploying ASP.NET application.
  • Jonas Mayor - terrific C# software engineer who introduced me to IIS URL Rewrite module and ASP.NET middleware.

Introduction

gRPC

gRPC is an abbreviation for "Google Remote Procedure Calls". It is a high performance multi-platform technology for communications between a server and clients.

Here are the gRPC advantages:

  1. gRPC messages are much smaller than those of the text based protocols (REST and SOAP), correspondingly gRPC communications are much faster.
  2. gRPC can be used for Web communications.
  3. gRPC supports Server side streaming This is important, because many other popular Web protocols e.g. REST do not support streaming.
  4. gRPC (except from a browser) supports Client side as well as the bidirectional streaming (both client and server exchanging information via established streams at the same time). gRPC in browser not supporting client side streaming is a pretty bad limitation that might require switching to other technologies e.g. SignalR for chatty clients.
  5. Both Server and Client gRPC can be run virtually on any platform and use various different software languages including, but not limited to C#, Java, JavaScript, Python.
  6. gRPC is very simple and easy to learn.

 

Kestrel and ASP.NET 8.0

The gRPC service is implemented as part of Kestrel ASP.NET server.

Kestrel is a Microsoft Web server written entirely in .NET Core and because of that it is 100% multiplatform.

Kestrel is great for handling ASP.NET and gRPC requests/responses, but misses some Web server features and because of that is often deployed behind another Web server which plays role of a reverse proxy for ASP and gRPC requests/responses.

On Windows the reverse proxy server is usually IIS while on Linux it can be Apache or anything else:

Image 3

The integration between IIS and Kestrel comes naturally in .NET 8.0 and requires almost no code change.

Avalonia

Avalonia is a great open source .NET framework for writing UI on multiple platforms (including Windows, MacOS, Linux, WebAssembly, Android and iOS) using C# and XAML.

On top of being multiplatform, Avalonia framework is considerably better and more powerful than WPF or any JavaScript/TypeScript framework.

Avalonia allows creating applications of great speed and quality and has very few differences in the way it behaves on various platforms.

Avalonia can be used for creating Web applications using WebAssembly.

The Main Purpose of the Article

This article provides easy and well explained samples for building and deploying gRPC services using IIS/Kestrel ASP.NET functionality on Windows and consuming them by various gRPC Clients. Here are the gRPC clients presented in the article:

  1. C# Console Program (would work exactly the same in C# desktop applications on any platform).
  2. HTML/JavaScript Web broser client.
  3. C# Non-Blazor Avalonia WebAssembly client. The reason I prefer not using Blazor is because from different sources on the Web I read that non-Blazor technology for C# WebAssembly and its interaction with HTML/JavaScript is more stable.

Important Note on Using ASP.NET

I'd like underscore that ASP.NET is often used to generate the HTML/JavaScript pages on the fly.

For application speed sake and clarity reasons, I prefer using ASP.NET for providing backend services only without almost any HTML/JavaScript generation.

One exception to the rule above is - sometimes, I add the constant global configuration parameters to ASP.NET pages via code generation only at the initial state (when the page is loaded the first time).

The AWebPros.com Website Demonstrating the gRPC Samples running under IIS/Kestrel

Using the code described in this article, I built a Website ASP gRPC Samples demonstrating web HTML/JavaScript and Avalonia WebAssembly client with IIS/Kestrel web server.

This article concentrates primarily on gRPC related code.

My wonderful journey through building and deploying a real life APS.NET web site I plan to describe in future articles. In particular I plan covering the following topics:

  1. WebAssembly, Avalonia and ASP.NET.
  2. Installing Hosting Bundle so that your ASP.NET would run under IIS.
  3. Using IIS/Kestrel in and out of hosting model.
  4. Using Cors.
  5. Obtaining and deploying a free SSL certificate so that your website would not be marked as insecure by Web browsers.
  6. Wiring ASP.NET Response Compression for WebAssembly files to improve speed.
  7. Deploying ASP.NET web sites using Publish.

No gRPC Client Streaming in Browser (unfortunately)

There is a known limitation of grpc-web (the only gRPC framework that can be used for gRPC browser based clients) - it allows only server side streaming (gRPC-Web Streaming).

No client streaming or bidirectional streaming is allowed.

Using gRPC over WebAssembly has appearance of allowing Client side and bidirectional streaming, but in fact it is not correct.

As we shall learn from the samples below, when you try to stream from a Web Browser client, the messages are not sent one by one, but instead they are accumulated on the client until the client indicates the end of streaming. Then all the accumulated messages are sent to the server together.

gRPC ASP.NET Code Samples

The Source Code

The source code for this article is located under ASP.NET gRCP Samples.

In order to maximize the code reuse between various samples, I built an all encompassing solution - AspGrpcTests.sln (which you never have to open) and a number of filtered solutions (.slnf) files around it. The .slnf files filter in the functionality specific to each sample.

Kestrel only Code Sample

Opening and Running the Kestrel only Solution

Kestrel can be used without IIS as a stand-alone process or Windows service. This sample (located in GrpcKestrelOnlyImplementation.slnf solution filter) demonstrates gRPC communications between a Kestrel-only server running as a console process and a local C# console client.

To run this sample open GrpcKestrelOnlyImplementation.slnf in Visual Studio 2022. You can see the following projects and files in the solution explorer:

Image 4

GrpcServerProcess is the project containing the Kestrel server and ConsoleTestClient is the project to start the local gRPC C# client.

Start the server process first (by right-clicking GrpcServerProcess and selecting Debug->Run Without Debugging). This will start the Kestrel server as a process on your windows. Note, that since the output of the server in not in HTML format, it will start a browser displaying an error something like (This local page can't be found). Do not worry about it and do not kill the browser if you want your server to continue running.

Then start the console client (project ConsoleTestClient) in the similar fashion. Here is what will be printed on the client console:

Unary server call sample:
Hello Joe Doe!!!


Streaming Server Sample:
Hello Joe Doe 1
Hello Joe Doe 2
Hello Joe Doe 3
Hello Joe Doe 4
Hello Joe Doe 5
Hello Joe Doe 6
Hello Joe Doe 7
Hello Joe Doe 8
Hello Joe Doe 9
Hello Joe Doe 10
Hello Joe Doe 11
Hello Joe Doe 12
Hello Joe Doe 13
Hello Joe Doe 14
Hello Joe Doe 15
Hello Joe Doe 16
Hello Joe Doe 17
Hello Joe Doe 18
Hello Joe Doe 19
Hello Joe Doe 20


Streaming Server Sample with Error:
Hello Joe Doe 1
Hello Joe Doe 2
Hello Joe Doe 3
Hello Joe Doe 4
Hello Joe Doe 5
Hello Joe Doe 6
Hello Joe Doe 7
Hello Joe Doe 8
Hello Joe Doe 9
Hello Joe Doe 10
Hello Joe Doe 11
Status(StatusCode="Internal", Detail="Error Status Detail (for the client)")


Streaming Client Sample:
Hello Client_1, Client_2, Client_3


Bidirectional Streaming Client/Server Sample:
Hello Client_1_1
Hello Client_1_2
Hello Client_1_3
Hello Client_1_4
Hello Client_2_1
Hello Client_2_2
Hello Client_2_3
Hello Client_2_4
Hello Client_3_1
Hello Client_3_2
Hello Client_3_3
Hello Client_3_4

There are five gRPC scenarios demo'ed by this sample:

  1. Unary sample - client sends "Joe Doe" string to the server and it returns "Hello Joe Doe!".
  2. Streaming server sample - client sends single string ("Joe Doe") and returns multiple greetings.
  3. Streaming server sample with error - same as the streaming server sample above, only the server throws an RpcException in the middle of the stream (after the 11th iteration).
  4. Streaming client sample - client send multiple strings ("Client_1", "Client_2" and "Client_3") and the server concatenates them and returns the "Client_1, Client_2, Client_3" string.
  5. Bidirectional streaming sample - the client sends 3 requests within ("Client_1", "Client_2" and "Client_3") and the server responds with 4 responses for each of the client requests (everything is happening in simultaneously - new client requests can arrive while the server responds to the old ones).

 

A Note on the Code Overview

Since similar or identical code is used in the rest of the samples, I will provide a very detailed review of the gRPC server and client code in this sample, while in the future samples, I'll be only emphasizing the differences with the current code.

Source Code Overview

The source code for this sample consists of 4 projects:

  1. GrpcServerProcess - starting and running Kestrel server.
  2. GreeterImpl - a re-usable library containing the gRPC server functionality implementation (GrpcServerProcess depends on it).
  3. Protos - containing the Greeter.proto gRPC proto file shared between the server (via GreeterImpl) and the client.
  4. ConsoleTestClient - the console client for running all the client gRPC tests against the server (it depends on Protos project as well).

 

The project dependencies are reflected on the diagram below:

Image 5

Protos project contains Greeter.proto file defining gRPC protos for the gRPC methods:

C#
syntax = "proto3";

// specifies the C# namespace for C# generated code
option csharp_namespace = "simple";

service Greeter
{
	// client takes HelloRequest and returns HelloReply
	rpc SayHello (HelloRequest) returns (HelloReply);

	// streams multiple server replies to a single client request
	rpc ServerStreamHelloReplies (HelloRequest) returns (stream HelloReply);

	// streams multiple server replies to a single client request
	// (throwing a server exception in the middle)
	rpc ServerStreamHelloRepliesWithError (HelloRequest) returns (stream HelloReply);

	// streams multiple client request producing a single server reply
	// when the client stream is complete. 
	rpc ClientStreamHelloRequests(stream HelloRequest) returns (HelloReply);

	// streams multiple server replies for each of the streamed client requests.
	// The replies start streaming before the client stream is completed, 
	// providing simultaneous bi-directional communications. 
	rpc ClientAndServerStreamingTest(stream HelloRequest) returns (stream HelloReply);
}

// HelloRequest has only one string field - name
message HelloRequest
{
	string name = 1;
}

// HelloReply has only one string name - msg
message HelloReply
{
	string msg = 1;
}  

All the Greeter methods take HelloRequest message(s) as requests and return HelloReply messages as replies. I could have used built-in string protobuf Type for both, but wanted to show a more complex example with request and reply types defined in the proto file.

Note that there is an integer specified next to a field within HelloRequest and HelloReply protos, e.g.:

C#
string name = 1;  

This integer should uniquely define a protobuf field, so that if new fields are added to the type , they should have different integers - in that case, the new expanded type will be backward compatible.

The most interesting server code is located within GreeterImplementation.cs file under the reusable GreeterImpl project.

Take a look at GreeterImpl.csproj file. It shows how to add a reference to a proto file contained by a different project:

XML
<ItemGroup>
	<Protobuf Include="..\Protos\Greeter.proto" GrpcServices="Server" />
    ...
</ItemGroup>  

We simply provide a relative path to the file.

The GrpcServices="Server" means that the server skeleton will be generated for the proto methods. Note that GreeterImpl project also has a reference to Grpc.AspNetCore package which has references needs to the generated the server skeleton.

GreeterImplementation class of GreeterImpl project inherits from the generated skeleton simple.Greeter.GreeterBase class. It provides overriding implementations for all the gRPC server methods, e.g here is the override for the simplest - single request/single reply SayHello(...) method:

C#
public override async Task<helloreply> SayHello(HelloRequest request, ServerCallContext context)
{
    string name = request.Name;

    return new HelloReply { Msg = $"Hello {name}!!!" };
}  
</helloreply>

GrpcServerProcess is the actual project that starts Kestrel server supporting the gRPC method implementations defined within GreeterImplementations class.

The local server port is defined within its appsettings.json file:

XML
"Kestrel": {
  "Endpoints": {
    "Grpc": {
      "Url": "https://localhost:55003",
      "Protocols": "Http2"
    }
  }
}  

The rest of its functionality is contained within Program.cs file:

C#
using GrpcServerProcess;

// create builder
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();

// add grpc service
builder.Services.AddGrpc();

// create the Kestrel application
var app = builder.Build();

// specifies GreeterImplementation as the gRPC service to run and 
// channel to it all the requests that specify 55003 port
app.MapGrpcService<GreeterImplementation>().RequireHost("*:55003");

// runs Kestrel server. 
app.Run();  

ConsoleTestClient project also references Greeter.proto file but with GrpcServices="Client": That instructs the Visual Studio to generate client proxies for Greeter methods.

ConsoleTestClient also needs to provide references to Grpc.Net.Client, Google.Protobuf and Grpc.Tools packages:

XML
<ItemGroup>
    <PackageReference Include="Grpc.Net.Client" Version="2.62.0" />
    <PackageReference Include="Google.Protobuf" Version="3.25.1" />
    <PackageReference Include="Grpc.Tools" Version="2.62.0">
        <PrivateAssets>all</PrivateAssets>
        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <Protobuf Include="..\Protos\Greeter.proto" GrpcServices="Client" />
</ItemGroup>  

The really interesting custom code is server implementations of gRPC methods and client calls to those methods. Server implementations are contained within GreeterImplementations.cs file (under the re-usable GreeterImpl project) and client calls are contained within Program.cs file of the ConsoleTestClient projects.

In the subsequent sections below I shall explain those methods one by one.

Single Request / Single Reply SayHello(...) method

This is the simplest method to implement and to call (since there is no either server or client side streaming).

Here is the protobuf code for this method from Greeter.proto file located under Protos project:

C#
service Greeter
{
	// client takes HelloRequest and returns HelloReply
	rpc SayHello (HelloRequest) returns (HelloReply);
    ...
}

Here is the client code (from ConsoleTestClient/Program.cs file):

C#
// get the channel connecting the client to the server
var channel =
    GrpcChannel.ForAddress("https://localhost:55003");

// create the GreeterClient service
var greeterGrpcClient = new Greeter.GreeterClient(channel);

string greetingName = "Joe Doe";

Console.WriteLine($"Unary server call sample:");
// call SetHello RPC on the server asynchronously and wait for the reply.
var reply =
    await greeterGrpcClient.SayHelloAsync(new HelloRequest { Name = greetingName });

Console.WriteLine(reply.Msg);  

The code at the top of the snippet above connects the gRPC client to the grpc server at "https://localhost:55003" url and creates the greeterGrpcClient object to call the server methods.

The server call itself takes only one line:

C#
// call SetHello RPC on the server asynchronously and wait for the reply.
var reply = await greeterGrpcClient.SayHelloAsync(new HelloRequest { Name = greetingName });  

We create the HelloRequest object setting its name to "Joe Doe", send it over to the server and wait for the reply.

reply.Msg will contain the "Hello Joe Doe!" greeting string.

The server implementation (located within GreeterImplementation.cs file) is also very simple:

C#
public override async Task<helloreply> SayHello(HelloRequest request, ServerCallContext context)
{
    string name = request.Name;

    // return HelloReply with its message set to the greeting string
    return new HelloReply { Msg = $"Hello {name}!!!" };
}  
</helloreply>

The client call will result in the following msg printed to the console:

Hello Joe Doe!!!  

Server Streaming Samples

There are two Server Streaming samples - one plain ServerStreamHelloReplies(...) method and the other one with server throwing an RpcExeption in the middle of the streaming responses being sent back to the client ServerStreamHelloRepliesWithError(...).

Here is the protobuf code for these two methods:

service Greeter
{
    ...
    // streams multiple server replies to a single client request
    rpc ServerStreamHelloReplies (HelloRequest) returns (stream HelloReply);

    // streams multiple server replies to a single client request
    // (throwing a server exception in the middle)
    rpc ServerStreamHelloRepliesWithError (HelloRequest) returns (stream HelloReply);        
    ...
} 

Here is the client code for the plain method (without server error):

C#
// get the serverStreaming call containing an asynchronous stream
var serverStreamingCall = greeterGrpcClient.ServerStreamHelloReplies(new HelloRequest { Name = greetingName });

await foreach(var response in serverStreamingCall.ResponseStream.ReadAllAsync())
{
    // for each async response, print its Msg property
    Console.WriteLine(response.Msg);
} 

The result of this client method call will be a stream of messages printed to the console:

Hello Joe Doe 1
Hello Joe Doe 2
Hello Joe Doe 3
Hello Joe Doe 4
Hello Joe Doe 5
Hello Joe Doe 6
Hello Joe Doe 7
Hello Joe Doe 8
Hello Joe Doe 9
Hello Joe Doe 10
Hello Joe Doe 11
Hello Joe Doe 12
Hello Joe Doe 13
Hello Joe Doe 14
Hello Joe Doe 15
Hello Joe Doe 16
Hello Joe Doe 17
Hello Joe Doe 18
Hello Joe Doe 19
Hello Joe Doe 20  

The client code for the server streaming with error is very similar, only its is encased within try/catch block:

C#
// get the serverStreaming call containing an asynchronous stream
var serverStreamingCallWithError = greeterGrpcClient.ServerStreamHelloRepliesWithError(new HelloRequest { Name = greetingName });
try
{
    await foreach (var response in serverStreamingCallWithError.ResponseStream.ReadAllAsync())
    {
        // for each async response, print its Msg property
        Console.WriteLine(response.Msg);
    }
}
catch(RpcException exception)
{
    // prints the exception message
    Console.WriteLine(exception.Message);
}  

And here is what it prints to the console:

Hello Joe Doe 1
Hello Joe Doe 2
Hello Joe Doe 3
Hello Joe Doe 4
Hello Joe Doe 5
Hello Joe Doe 6
Hello Joe Doe 7
Hello Joe Doe 8
Hello Joe Doe 9
Hello Joe Doe 10
Hello Joe Doe 11
Status(StatusCode="Internal", Detail="Error Status Detail (for the client)")  

The server throws an error after the 11th iteration and the error message intended for the client is "Error Status Detail (for the client)" (the server can write another, more detailed, error message to its log).

Now let us take a look at the server implementations of the server streaming methods within GreeterImplementation.cs file:

C#
public override async Task ServerStreamHelloReplies
(
    HelloRequest request,
    IServerStreamWriter<HelloReply> responseStream,
    ServerCallContext context)
{
    await ServerStreamHelloRepliesImpl(request, false, responseStream, context);
}


public override async Task ServerStreamHelloRepliesWithError
(
    HelloRequest request,
    IServerStreamWriter<HelloReply> responseStream,
    ServerCallContext context)
{
    await ServerStreamHelloRepliesImpl(request, true, responseStream, context);
}  

Both methods call ServerStreamHelloRepliesImp(...) method, one passing the argument throwException set to false and the other set to true.

C#
private async Task ServerStreamHelloRepliesImpl
(
    HelloRequest request,
    bool throwException,
    IServerStreamWriter<HelloReply> responseStream,
    ServerCallContext context)
{
    // get the name from the client request
    string name = request.Name;

    for (int i = 0; i < 20; i++)
    {
        await Task.Delay(200); // delay by 0.2 of a second

        // write the reply asynchronously
        await responseStream.WriteAsync(new HelloReply { Msg = $"Hello {name} {i + 1}" });

        // cancel stream if cancellation is requested
        context.CancellationToken.ThrowIfCancellationRequested();

        // throw the RpcException (propagated to the client) 
        // after the 11th iteration if throwException argument is passed as true
        if (i == 10 && throwException)
        {
            // sets the status code and Error messages for the client and the server. 
            // the status code and the error message for the client will be sent over to the client,
            // while the error message for the server can be logged or acted upon in various ways 
            // on the server.
            throw new RpcException(new Status(StatusCode.Internal, "Error Status Detail (for the client)"), "ERROR: Cannot Continue with streaming (server error)!");
        }
    }
}  

Note that the line:

// cancel stream if cancellation is requested
context.CancellationToken.ThrowIfCancellationRequested();  

serves to cancel the server streaming from the client side. Client side cancellation will be demo'ed later when we discuss the web clients.

Client Streaming Sample

Next sample (method Greeter.ClientStreamHelloRequests(...)) demonstrates async streaming from the client, accumulating the results on the server and returning the single value result to the client after the client streaming ended.

Here is the protobuf declaration of this methods:

service Greeter
{
    ...
    // streams multiple client request producing a single server reply
    // when the client stream is complete. 
    rpc ClientStreamHelloRequests(stream HelloRequest) returns (HelloReply);
    ...
}  

Here is the client code:

C#
var clientSreamingCall = greeterGrpcClient.ClientStreamHelloRequests();

for(int i = 0; i < 3;  i++)
{
    // stream requests from the client to server
    await clientSreamingCall.RequestStream.WriteAsync(new HelloRequest { Name = $"Client_{i + 1}" });
}

// inform the server that the client streaming ended
await clientSreamingCall.RequestStream.CompleteAsync();

// get the resulting HelloReply from the server
var clientStreamingResponse = await clientSreamingCall;

// print the resulting message
Console.WriteLine(clientStreamingResponse.Msg);  

The server returns string "Hello " followed by the comma separated concatenation of the messages from the client:

Hello Client_1, Client_2, Client_3  

Here is the server code:

C#
public override async Task<HelloReply> ClientStreamHelloRequests(IAsyncStreamReader<HelloRequest> requestStream, ServerCallContext context)
{
    string message = "Hello ";

    bool first = true;

    // for each message from the client (read asynchronously)
    await foreach (var inputMessage in requestStream.ReadAllAsync())
    {
        if (!first)
        {
            // if not the first message prepend it with ", " string
            message += ", ";
        }

        // add the Name from the message
        message += inputMessage.Name;
        first = false;
    }

    // after streaming ended return the HelloReply the corresponding Msg property
    return new HelloReply { Msg = message };
}  

Note, that the server receives each of the client messages as they are streamed, without waiting for the client streaming to end. You can observe it by running the server in a debugger and putting a breakpoint within the await foreach(...) loop.

Bidirectional Streaming Sample

Finally we came the most interesting and complex sample - the one that demonstrates streaming both client requests and server replies simultaneusly: the server does not wait for the client to finish its streaming it starts replying after each client request arrives.

The protobuf method is called Greeter.ClientAndServerStreamingTest(...):

service Greeter
{
    ...
    // streams multiple server replies for each of the streamed client requests.
    // The replies start streaming before the client stream is completed, 
    // providing simultaneous bi-directional communications. 
    rpc ClientAndServerStreamingTest(stream HelloRequest) returns (stream HelloReply);
}    

Note that both requests and replies are streamed.

Here is the client implementation of calling bidirectional streaming functionality:

C#
var clientServerStreamingCall = greeterGrpcClient.ClientAndServerStreamingTest();

// the task to be run to get and print the streamed responses to the server
var readTask = Task.Run(async () =>
{
    await foreach (var reply in clientServerStreamingCall.ResponseStream.ReadAllAsync())
    {
        // for every server reply, simply print the message
        Console.WriteLine(reply.Msg);
    }
});

// stream 3 requests to the server. 
for (int i = 0; i < 3; i++)
{
    // write the request into the request stream.
    await clientServerStreamingCall.RequestStream.WriteAsync(new HelloRequest { Name = $"Client_{i + 1}" });

    // delay by one second - it is important to 
    // make sure that the server responds immediately after receiving the first
    // client request and does not wait for the client stream to complete
    await Task.Delay(1000);
}

await Task.WhenAll(readTask, clientServerStreamingCall.RequestStream.CompleteAsync());  

The readTask defined at the top will be used to get the streaming replies, one by one.

Here is what the client prints to console:

Hello Client_1_1
Hello Client_1_2
Hello Client_1_3
Hello Client_1_4
Hello Client_2_1
Hello Client_2_2
Hello Client_2_3
Hello Client_2_4
Hello Client_3_1
Hello Client_3_2
Hello Client_3_3
Hello Client_3_4

For every string coming from the client, the server sends back 4 replies e.g. for Client_1, the replies will be Client_1_1, Client_1_2, Client_1_3 and Client_1_4.

Note that the interval between streaming client messages to the server is pretty large - 1 second and yet, there is almost no delay in server replies. This is because the server starts replying after the first client message is received and continues replying without waiting for the client streaming to be completed.

This is what the real bi-directional streaming is and unfortunately it will NOT work from the browser as will be explained below.

Overview of Browser Based Code Sample Code Samples

The rest of the ASP.NET gRPC code samples will be browser based. One sample demonstrates the HTML/JavaScript using gRPC to exchange information with the server and the other Avalonia WebAssembly.

The server gRPC code for those samples will be exactly the same as in the previous sample - located within GreeterImplementation.cs file under GreeterImpl project. Because of that we shall concentrate primarily on the client code and ASP.NET specific server code.

HTML/JavaScript ASP.NET gRPC Sample

Running the Sample

Start the solution filter AspGrpcServerWithRazorClient.slnf. Make AspGrpcServerWithRazorClient project to be your startup project. Build the project and run it under the debugger.

Here is the page you'll see:

Image 6

You can try changing the name (or leaving it set to "Nick"), then clicking "Get Single Greeting" button and you'll get "Hello Nick!!!" printed in the browser:

Image 7

Now try clicking "Get Multiple Greetings" button. You will see multiple greeting streaming from the server from "Hello Nick 1" to "Hello Nick 20" and at the end it will print "STREAMING ENDED" (if you let it run to the end):

Image 8

You can also cancel the server streaming at any point by pressing button "Cancel Streaming".

Pressing button "Get Multiple Greetings with ERROR" will call the on the server method that will throw an RpcException after 11th iteration. Here is how the resulting error is displayed:

Image 9

Creating the ASP.NET Project for Hosting the Sample

I created the ASP.NET project AspGrpcServerWithRazorClient.csproj by choosing "ASP.NET Core Web App (Razor Pages)" project type:

Image 10

Then I modified its Program.cs, Index.cshtml and _Layout.cshtml as will be detailed below.

Generating JavaScript Client Proxies from Protobuf Code

As a prerequisite for everything that follows, please, install nodeJS and npm e.g. by going to Node JS Install, downloading the node .msi installer and running it on your windows machine.

In order to build the server you need to

  1. Install protoc protobuffer compiler e.g. from Protoc Compiler Download by downloading and unzipping protoc-26.1-win64.zip (or some other file depending on your machine). Make sure protoc.exe is in your path.
  2. Generate the JavaScript client proxies from Greeter.proto protobuf file using protoc compiler. Protoc will generate nodeJS JavaScript proxies. Here is the command that needs to be run from a command line from inside the Protos folder (of the Protos project) to generate the client proxies:
    protoc -I=. Greeter.proto --js_out=import_style=commonjs:..\AspGrpcServerWithRazorClient\wwwroot\dist --grpc-web_out=import_style=commonjs,mode=grpcwebtext:..\AspGrpcServerWithRazorClient\wwwroot\dist
    This line is also contained in README.txt file within the same folder.
  3. The files Greeter_grpc_web_pb.js and Greeter_pb.js will be created under AspGrpcServerWithRazorClient\wwwroot\dist folder.
    Image 11
    These files are nodeJS modules and not fit to be run from the browser.
  4. In order to convert the client proxies to ES modules (that can be run in the browser) I use "WebPack Task Runner" VS2022 extension:
    Image 12
    You need to install this extension also.
  5. There is a tiny JavaScript file AspGrpcServerWithRazorClient/wwwroot/dist/client.js that refers to all the functionality that the JavaScript client needs from the generated Greeter_..._pb.js files:
    JavaScript
    const { HelloRequest, HelloReply } = require('./Greeter_pb.js');
    const { GreeterClient } = require('./Greeter_grpc_web_pb.js');
    
    global.HelloRequest = HelloRequest;
    global.HelloReply = HelloReply;
    global.GreeterClient = GreeterClient;
    
    This file is used by webpack for generating the main.js file that can be used by the browser client.
  6. In order to use webpack file generation, we need to create webpack.config.js file at the project level. Here is its content:
    JavaScript
    const path = require('path');
    
    module.exports = {
        mode: 'development',
        entry: './wwwroot/dist/client.js',
        output: {
            path: path.resolve(__dirname, 'wwwroot/dist'),
            filename: 'main.js',
        },
    };
    
    It will instruct webpack to take client.js and every file it depends on and based on them create main.js file (located under the same dist folder) which can be used by the JavaScript browser clients.
  7. In order to do the conversion - right click webpack.config.js file in the solution explorer and choose "Task Runner Explorer":
    Image 13
    It should open the Task Runner Explorer utility. Click "Run Development" or "Run Production" and choose "Run" menu option:
    Image 14
    It will generate file main.js which can be used in browser JavaScript code.

 

HTML/JavaScript Client Code Overview

HTML/JavaScript client code that uses the proxies generated in the previous subsection is located within AspGrpcServerWithRazorClient/Pages/Index.cshtml file. The file is virtually all HTML/JavaScript with no ASP code generation.

The HTML part of the file defines labels, buttons and divs (as placeholders for adding text):

HTML
<h1>ASP gRPC Samples</h1>
<label>Enter Name:</label>
<input type="text" id="TheName" value="Nick">
<h2>Non-Streaming (Single Value) gRPC Sample:</h2>
<button type="button" id="GetSingleGreetingButton">Get Single Greeting</button><br /><br />
<div id="TheSingleValueResponse" style="font-weight:bold;font-size:20px;font-style:italic"></div>

<h2>Server Streaming gRPC Sample (multiple greetings from the Server):</h2>
<button type="button" id="GetMultipleGreetingsButton">Get Multiple Greeting</button>
<button type="button" id="GetMultipleGreetingsWithErrorButton">Get Multiple Greeting with ERROR</button>
<button type="button" id="CancelStreamingButton">Cancel Streaming</button>
<br /><br />
<div id="TheStreamingResponse" style="font-weight:bold;font-size:20px;font-style:italic"></div>
<label id="TheErrorLabel" style="visibility:collapse">Streaming Error:</label>
<div id="TheStreamingError"></div>
<div id="TheStreamingEnded"></div>  

Then two modules are added -

  1. main.js file generated in the previous subsection. main.js module is used get the client proxy code).
  2. jquery.min.js - for finding HTML tree nodes and modifying them.

 

HTML
<script src="./dist/main.js"></script>
<script src="./dist/jquery.min.js"></script>  

And then finally there is JavaScript client code within <script type="text/javascript"> HTML tag.

Client Code to Create the gRPC Greeter Client

JavaScript
// get url of the current ASP website
var location = window.location;
var url = location.origin;

// use this url to create the client. 
var greeterServiceClient = new GreeterClient(url);  

After creating the client greeterServiceClient, we use it for gRPC service calls.

Calling Single Request / Single Reply SayHello(...) Service from JavaScript Client

Here is how we call SayHello(...) service from JavaScript client:

JavaScript
// create request object
var request = new HelloRequest();

// Read the name from TheName TextBox
var name = $("#TheName").val();

// set the name parameter of the request object
request.setName(name);

// send hello request and set up a callback to be fired 
// once the response is obtained.
greeterServiceClient.sayHello(request, {}, (err, response) => {
    // this is the body of sayHello service callback 
    // fired on response coming from gRPC server

    // get the message from the response, assign 
    // it as text to "#TheSingleValueResponse" div area
    var msg = response.getMsg();
    $("#TheSingleValueResponse").text(msg);
});  

This code itself is assigned using jQuery to be a callback on the "#GetSingleGreetingButton" click:

JavaScript
$("#GetSingleGreetingButton").on(
    "click",
    () => {
        ...
    });

Calling Streaming gRPC Services form the Client

For server streaming calls we define a global variable var stream = null; and then use it to call the streaming services and to cancel them if needed.

For the sake of the code reuse there is a single JavaScript getStreamedGreetings(greeterServiceClient, throwError) method whose second argument is a Boolean flag - that should be set to true to call the gRPC streaming service that throws an error after the 11th iteration and false for the one that allows the streaming service to run all the way to the end.

Here is how streaming service invocation callbacks are assigned to the corresponding buttons:

JavaScript
// No error coming from the server
$("#GetMultipleGreetingsButton").on(
    "click",
    () => getStreamedGreetings(greeterServiceClient, false)
);

// Server error after 11th iteration
$("#GetMultipleGreetingsWithErrorButton").on(
    "click",
    () => getStreamedGreetings(greeterServiceClient, true)
);  

Here is the getStreamedGreetings(...) method implementation (with detailed comments):

JavaScript
getStreamedGreetings = (greeterServiceClient, throwError) => {

    // reset current STREAMING ENDED message text to empty
    $("#TheStreamingEnded").text('');
        
    // make the current error label invisible
    $("#TheErrorLabel").css("visility", "collapse");

    // reset the streaming error message to emtpy
    $("#TheStreamingError").text('');

    // create a HelloRequest object
    var request = new HelloRequest();

    // get the game from TheName TextBox
    var name = $("#TheName").val();

    // set the Name parameter of the request
    request.setName(name);

    // calling the streaming server methods returns the client handle to 
    // the stream (which we assign to stream global variable)
    if (!throwError) {
        // call the service that does not throw an Exception
        stream = greeterServiceClient.serverStreamHelloReplies(request, {});
    }
    else {
        // call the service that does throws RpcException after 11th iteration
        stream = greeterServiceClient.serverStreamHelloRepliesWithError(request, {});
    }

    // On stream message arriving, assign the text received contained with that last message
    // to the the corresponding text area (div) element
    stream.on('data', (response) => {
        var msg = response.getMsg();
        $("#TheStreamingResponse").text(msg);
    });

    // do something on server status changing
    stream.on('status', (status) => {

    });

    // assing error text on server error
    stream.on('error', (err) => {
        $("#TheErrorLabel").css("visility", "visible");
        $("#TheStreamingError").text(err);
        stream = null;
    });

    // signify that the server streaming ended
    stream.on('end', () => {
        $("#TheStreamingEnded").text("STREAMING ENDED");
        stream = null;
    })  

Canceling Server Streaming

All one needs to do is to cancel a server stream is to call stream.cancel("Cancellation Message").

ASP.NET Code for Building and Starting ASP.NET Server

This code is located within well documented Program.cs file under AspGrpcServerWithRazorClient project:

C#
using GrpcServerProcess;

// create ASP.NET application builder.
var builder = WebApplication.CreateBuilder(args);

// Add a service generating razor pages
builder.Services.AddRazorPages();

// add a service for grpc
builder.Services.AddGrpc();

// build the application
var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

// use default file (Index.cshtml) when no path is specified after 
// server:port combination
app.UseDefaultFiles();

// allow using static files (e.g. .js, wasm etc)
app.UseStaticFiles();

// use grpc-web 
app.UseGrpcWeb();

// allow razor pages generation
app.MapRazorPages();

// create the GreeterImplementation service and allow it to be accessed from grpc-web
app.MapGrpcService<GreeterImplementation>().EnableGrpcWeb();

// start the ASP.NET server
app.Run();  

ASP.NET Avalonia/WebAssembly gRPC Code Sample

Running the Sample

To run the sample, please, open AspGrpcWithAvaloniaClients.slnf solution filter file, make AspGrpcServer the start-up project, then re-build it and start it.

Here is the screen you are going to see (after a second or too):

Image 15

The upper two samples (request/reply and server samples) will behave almost exactly the same as those of the previous section:

Image 16

The two bottom samples correspond to Client streaming and bi-directional client-server streaming and the purpose is to show that they will NOT work correctly in browser.

Indeed, if you press "Test Streaming Client" or "Test Streaming Client and Server" buttons, the server will get the messages and start responding only when the client stream is completed (it is 5 seconds for Streaming Client sample and 3 second for bi-directional sample). Which essentially means that client streaming is not working (even though C# generates client proxies for those protobuf methods).

Creating Client C# Avalonia Projects

To create an Avalonia WebAssembly project I use instructions from Creating Avalonia Web Assembly Project:

  1. I install wasm-tools (or make sure they are installed) by running
    dotnet workload install wasm-tools        
    from a command line.
  2. I update to the latest avalonia dotnet templates by running command:
    dotnet new install avalonia.templates        
  3. I create a folder for the project (in my case it is called AvaGrpcClient) and cd to it using the command line.
  4. From within that folder, I run from the command line:
    dotnet new avalonia.xplat        
  5. This will create the shared project AvaGrpcClient (within the same-named folder) and a number of platform specific projects.
  6. I remove most of the platform specific projects leaving only AvaGrpcClient.Browser (for building the Avalonia WebAssembly bundle) and AvaGrpcClient.Display (for debugging and faster prototyping if needed).

 

Client Avalonia Code

In order for the project AvaGrpcClient to generate the gRPC client proxies, I add a reference to the protobuf file to AvaGrpcClient.csproj file with GrpcServices flag set to Client:

<Protobuf Include="..\..\Protos\Greeter.proto" GrpcServices="Client" />

I also add references to packages required by Grpc including Grpc.Net.Client.Web package needed specifically for the grpc to work in a browser:

<ItemGroup>
    <PackageReference Include="Grpc.Net.Client" Version="2.62.0" />
    <PackageReference Include="Google.Protobuf" Version="3.25.1" />
    <PackageReference Include="Grpc.Net.Client.Web" Version="2.62.0" />
    <PackageReference Include="Grpc.Tools" Version="2.62.0">
        <PrivateAssets>all</PrivateAssets>
        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    ...
</ItemGroup>  

Almost all Sample specific client code is located within MainView.xaml.cs and MainView.xaml files. MainView.xaml specifies the visual layout while all the code interacting with the server is inside MainView.xaml.cs file.

The connection to the client is established within MainView class constructor:

C#
var channel =
    GrpcChannel.ForAddress
    (
        CommonData.Url!, // server address
        new GrpcChannelOptions
        {
            // indicates the browser grpc connection
            HttpHandler = new GrpcWebHandler(new HttpClientHandler()) 
        });

// create the GreeterClient service
_greeterGrpcClient = new Greeter.GreeterClient(channel);  

...
 _serverStreamCancellationTokenSource = new CancellationTokenSource();

Note the CommonData.Url static string property. It should contain the URL for connected to the Grpc calls ( nn my case it is the same as ASP.NET server URL).

This CommonData.Url property is set to the first argument passed to the Program.Main(...) method contained in AvaGrpcClient.Browser solution (solution that actually creates WebAssembly browser code as a number of .wasm files):

C#
internal sealed partial class Program
{
    private static async Task Main(string[] args)
    {
        // note we are assigning the first argument to CommonData.Url
        CommonData.Url = args[0];

        // replace the div with id "out" by the MainView object instance
        await BuildAvaloniaApp()
            .WithInterFont()
            .UseReactiveUI()
            .StartBrowserAppAsync("out");
    }

    public static AppBuilder BuildAvaloniaApp()
        => AppBuilder.Configure<App>();
}  

Note also that calling the following code

C#
// replace the div with id "out" by the MainView object instance
await BuildAvaloniaApp()
    .WithInterFont()
    .UseReactiveUI()
    .StartBrowserAppAsync("out");  

essentially replaces an HTML element with id "out" by the Avalonia Browser application (in our case it is MainView object instance).

The callbacks fired when the corresponding buttons are pressed are defined as methods within MainView.xaml.cs file.

Here is the code for calling Single Request/Single Response (Unary) service SayHello from the client:

C#
private async void TestUnaryHelloButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
    // call simple single request/single response SayHello service
    var reply =
        await _greeterGrpcClient.SayHelloAsync(new HelloRequest { Name = GreetingName });

    // display the result
    HelloResultText.Text = reply.Msg;
}  

where GreetingName property is the Text value from the TextBox defined in MainView.xaml file:

private string GreetingName => NameToEnter.Text ?? string.Empty;  

Here is the documented client code for testing server streaming:

C#
// test server streaming
private async void TestStreamingServerButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
    // set initial values to empty strings
    StreamingServerResultsText.Text = string.Empty;
    StreamingErrorText.Text = string.Empty;
    try
    {
        // get the server stream container
        var serverStreamingResponsesContainer = 
            _greeterGrpcClient.ServerStreamHelloReplies(new HelloRequest { Name = GreetingName });
            
        // foreach of the async responses from the server
        await foreach (var response in serverStreamingResponsesContainer.ResponseStream.ReadAllAsync(_serverStreamCancellationTokenSource.Token))
        {
            // change the text of the TextBox
            StreamingServerResultsText.Text = response.Msg;
        }
    }
    catch(RpcException exception)
    {
        // if an exception is throws, show the exception message
        StreamingErrorText.Text = $"ERROR: {exception.Message}";
    }
}  

Code for testing server streaming with error is exactly the same, only calls a different service ServerStreamHelloRepliesWithError(...):

C#
// foreach of the async responses from the server
var serverStreamingResponsesContainer = _greeterGrpcClient.ServerStreamHelloRepliesWithError(new HelloRequest { Name = GreetingName });  

The method below cancels server streaming from the client

C#
private void TestStreamingServerCancelButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
    // send signal to the server to cancel streaming 
    _serverStreamCancellationTokenSource?.Cancel();

    // change the streaming token
    _serverStreamCancellationTokenSource = new CancellationTokenSource();
}  

The next method demonstrates using the Client Streaming API. Because we work in C#, the Client Streaming API has been generated, and can be used - but, unfortunately it does not do the streaming from the browser. It waits until the client streaming is finished and then sends all the accumulated messages together:

C#
// test streaming client to the server
// unfortunately the client in a browser does not stream,
// it accumulates all the messages on the browser, and
// sends them together after the client indicates the end of streaming
private async void TestStreamingClientButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
    // reset the UI text to emtpy
    StreamingClientResultsText.Text = string.Empty;

    // create stream container
    var clientStreamContainer = _greeterGrpcClient.ClientStreamHelloRequests();

    for (int i = 0; i < 5; i++)
    {
        // push the messages into the request streams
        await clientStreamContainer.RequestStream.WriteAsync(new HelloRequest { Name = $"Client_{i + 1}" });

        await Task.Delay(1000);
    }

    // indicate the completion of the client streaming
    // Unfortunately it is only at this point that all the client messages
    // will be sent to the server. Essentially that means that there is 
    // no client streaming
    await clientStreamContainer.RequestStream.CompleteAsync();

    // get the server response
    var clientStreamingResponse = await clientStreamContainer;

    // set the visual text of the server response
    StreamingClientResultsText.Text = clientStreamingResponse.Msg;
}

And here is the code demonstrating bi-directional (client and server) streaming API suffering from the same problem (there is no Client side streaming from the browser - the browser accumulates client messages and sends the all together one the client closes the 'stream'):

C#
// Bi-Directional (Client and Server) streaming test. 
// Unfortunately the client messages are accumulated on the client side
// and sent to the server together only after
// the client indicates the end of streaming
// Which essentially means that there is no client streaming
private async void TestStreamingClientServerButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
    // create server and client streamd container
    var clientServerStreamContainer = _greeterGrpcClient.ClientAndServerStreamingTest();

    // reset the server reply text to empty
    StreamingClientServerResultsText.Text = string.Empty;

    // create an async task to asynchronously process the server responses 
    // as they come
    var readTask = Task.Run(async () =>
    {
        await foreach (var reply in clientServerStreamContainer.ResponseStream.ReadAllAsync())
        {
            // for each server response we assing it to show in the client browser
            await Dispatcher.UIThread.InvokeAsync(() => { StreamingClientServerResultsText.Text += reply.Msg + "\n"; });
        }
    });

    // push 3 client requests into the stream
    // unfortunately they'll accumulated on the client and sent together 
    // only after the client call RequestStream.CompleteAsync() method
    for (int i = 0; i < 3; i++)
    {
        await clientServerStreamContainer.RequestStream.WriteAsync(new HelloRequest { Name = $"Client_{i + 1}" });

        await Task.Delay(1000);
    }

    // wait for both the client and the server processing to finish.
    await Task.WhenAll(readTask, clientServerStreamContainer.RequestStream.CompleteAsync());
}  

ASP.NET Server Code

The server ASP.NET code is located within AspGrpcServer project.

There are two things to know about the project:

  1. I made it build-dependent on AvaGrpcClient.Browser project by right-clicking the solution and choosing Project Dependencies menu item and then choosing the main project AspGrpcServer and making it dependent on AvaGrpcClient.Browser.
    Image 17
    This ensures that every time you rebuild the server, the AvaGrpcClient.Browser project will be built before that and since the AvaGrpcClient.Browser project proj-depends on AvaGrpcClient, the AvaGrpcClient project will be built event before that.
  2. There is a post-build event defined within the AspGrpcServer project (see the bottom of AspGrpcServer.csproj file). This event copies AppBundle/_framework folder created by AvaGrpcClient.Browser build over to AspGrpcServer/wwwroot/_framework folder. Note - this folder (_framework) should be ignored by the version control tool.

 

I modified the Shared/_Layout.cshtml code to be simpler and also I modified the Index.cshtml file to contain a <div id="out"> which will be replaced by Avalonia WebAssembly MainView object instance (as was explained above).

Here is the code for Index.cshtml:

HTML
<body style="margin: 0px; overflow: hidden">
    <!-- out object to be replaced by the Avalonia C# view -->
    <div id="out">
        <div id="avalonia-splash">
            <div class="center">
                <h2 class="purple">
                    Powered by
                    <a class="highlight" href="https://www.avaloniaui.net/" target="_blank">Avalonia UI</a>
                </h2>
            </div>
            <img class="icon" src="~/Logo.svg" alt="Avalonia Logo" />
        </div>
    </div>
    <!-- load the mainForAvalonia.js module to trigger replacing 
       div with id="out" with Avalonia MainView object instance
    -->
    <script type='module' src="~/mainForAvalonia.js"></script>
</body>

On the last line of the above code - note that we are loading mainForAvalonia.js module. This module/file contains the JavaScript code that actually creates the Wasm and runs the wasm project in the browser.

Here is the JavaScript code contained inside mainForAvalonia.js file:

JavaScript
import { dotnet } from './_framework/dotnet.js'

const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);

const { getConfig, runMain } = await dotnet
    .withDiagnosticTracing(false)
    .withApplicationArgumentsFromQuery()
    .create();

const config = getConfig();

// the first argument is the main assembly name
// the second argument is a string array of args to be passed to the
// C# Program.Main(string[] args)
// we pass to it only one argument containing the ASP.NET server URL
// The same argument is set to CommonData.Url static C# property and 
// used the address to create the gRPC channel in C#
await runMain(config.mainAssemblyName, [window.location.origin]);  

Note the last line - this is where we we pass the current server URL to server also as the URL for creating gRPC client communication channel.

Now take a look at the server's start-up file AspGrpcServer/Program.cs. The difference from a similar file from the previous (HTML/JavaScript) sample is very small. The only difference is that we need to add Mime types to allow returning .wasm and some other files to the client. Here is how it is achieved:

C#
// add the content types that might be needed for 
// sending .wasm files back to the client. 
var contentTypeProvider = new FileExtensionContentTypeProvider();
var dict = new Dictionary<string, string>
    {
        {".pdb" , "application/octet-stream" },
        {".blat", "application/octet-stream" },
        {".bin", "application/octet-stream" },
        {".dll" , "application/octet-stream" },
        {".dat" , "application/octet-stream" },
        {".json", "application/json" },
        {".wasm", "application/wasm" },
        {".symbols", "application/octet-stream" }
    };
foreach (var kvp in dict)
{
    contentTypeProvider.Mappings[kvp.Key] = kvp.Value;
}
app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = contentTypeProvider });

We create build a FileExtensionContentTypeProvider object inserting needed new MIME types into it and then we pass it as ContentTypeProvider property of StaticFileOptions object to the app.UseStaticFiles(...) method. 

Conclusion

This is an article in which I provide easy samples and detailed explanations of all the use-cases for gRCP communications between various clients (including C# Console, HTML/JavaScritp and C# in Browser via WebAssembly clients), and gRPC-enabled ASP.NET server.

License

This article, along with any associated source code and files, is licensed under The MIT License