gRPC est taillé pour la communication entre services : contrat .proto, messages Protobuf binaires, transport HTTP/2. Mais un navigateur ne peut pas appeler directement un service gRPC, faute d’accès aux frames et aux trailers HTTP/2 depuis JavaScript. ASP.NET Core propose deux réponses complémentaires. gRPC-Web adapte le protocole pour qu’un client navigateur (JavaScript ou Blazor WebAssembly) parle encore Protobuf. Le JSON transcoding expose les mêmes méthodes sous forme d’API REST/JSON classique, appelable avec un simple fetch. Cet article met en place les deux sur un service d’agenda d’événements et les compare.
Cet article fait partie de la série gRPC : 5 sur 5.
- Part 1 - gRPC : comprendre le protocole et ses différences avec HTTP/REST
- Part 2 - gRPC Code-First en .NET : se passer des fichiers .proto
- Part 3 - gRPC : le streaming (server, client et bidirectionnel)
- Part 4 - gRPC : intercepteurs, deadlines et annulation
- Part 5 - Cet article
1. Le problème : gRPC et le navigateur
gRPC repose sur des fonctionnalités HTTP/2 que les API du navigateur (fetch, XMLHttpRequest) n’exposent pas : contrôle fin des streams et surtout trailers, dans lesquels voyage le statut gRPC (grpc-status). Deux stratégies existent :
graph LR
B1[Navigateur<br/>client gRPC-Web] -->|application/grpc-web<br/>Protobuf, HTTP/1.1 ou 2| W[ASP.NET Core<br/>UseGrpcWeb]
B2[Navigateur<br/>fetch / curl] -->|JSON<br/>GET /v1/events/1| T[ASP.NET Core<br/>JSON transcoding]
S[Service .NET<br/>client gRPC] -->|gRPC HTTP/2| G[Endpoint gRPC]
W --> SVC[AgendaServiceImpl]
T --> SVC
G --> SVC
style W fill:#4A90E2
style T fill:#F5A623
style SVC fill:#7ED321
Dans les deux cas, le code du service ne change pas : ce sont le pipeline et la configuration qui évoluent. Un même service peut donc répondre simultanément en gRPC natif, en gRPC-Web et en JSON.
Le service d’exemple
L’exemple est un AgendaService qui expose des événements (conférences, ateliers) : lecture d’un événement, liste paginée, création, et un flux des changements d’une salle. Son contrat complet, annoté pour le JSON transcoding, est détaillé en section 3 ; il est compilé dans le namespace AgendaApi. L’implémentation est un service gRPC ordinaire, ici avec un stockage en mémoire :
using Grpc.Core;
using AgendaApi;
public class AgendaServiceImpl : AgendaService.AgendaServiceBase
{
private static readonly Dictionary<int, CalendarEvent> Events = new()
{
[1] = new CalendarEvent
{
Id = 1, Title = "Atelier Blazor", StartsAt = "2026-05-12T09:00:00Z",
Room = "salle-a", IsPublic = true
}
};
public override Task<CalendarEvent> GetEvent(GetEventRequest request, ServerCallContext context) =>
Events.TryGetValue(request.Id, out var evt)
? Task.FromResult(evt)
: throw new RpcException(new Status(StatusCode.NotFound, $"Événement {request.Id} introuvable"));
// ListEvents, CreateEvent et WatchRoom suivent le même schéma
}
2. gRPC-Web
Le principe
gRPC-Web est une variante du protocole qui encode les trailers dans le corps de la réponse. Il fonctionne en HTTP/1.1 comme en HTTP/2, avec deux formats :
application/grpc-web: Protobuf binaire ;application/grpc-web-text: Protobuf encodé en base64, nécessaire au server streaming dans les navigateurs.
Historiquement, un proxy (Envoy) traduisait gRPC-Web en gRPC. En .NET, un middleware fait ce travail directement dans l’application. Envoy reste une option pertinente s’il est déjà en place dans l’infrastructure.
Côté serveur
dotnet add package Grpc.AspNetCore.Web
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
// CORS : nécessaire si la page n'est pas servie par le même domaine que l'API
builder.Services.AddCors(options => options.AddPolicy("GrpcWeb", policy =>
{
policy.WithOrigins("https://agenda.example.com")
.AllowAnyMethod()
.AllowAnyHeader()
// Le client doit pouvoir lire les headers propres à gRPC
.WithExposedHeaders("Grpc-Status", "Grpc-Message",
"Grpc-Encoding", "Grpc-Accept-Encoding", "Grpc-Status-Details-Bin");
}));
var app = builder.Build();
// Ordre : gRPC-Web puis CORS, avant le mapping des endpoints
app.UseGrpcWeb();
app.UseCors();
app.MapGrpcService<AgendaServiceImpl>()
.EnableGrpcWeb() // active gRPC-Web pour ce service
.RequireCors("GrpcWeb"); // applique la politique CORS
app.Run();
Pour activer gRPC-Web sur tous les services sans appeler EnableGrpcWeb() sur chacun : app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }).
// ❌ Politique CORS qui n'expose pas Grpc-Status :
// le navigateur masque le header et le client ne sait pas si l'appel a réussi
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
// ✅ Origines explicites + headers gRPC exposés
policy.WithOrigins("https://agenda.example.com")
.AllowAnyMethod().AllowAnyHeader()
.WithExposedHeaders("Grpc-Status", "Grpc-Message");
Le template gRPC n’active que HTTP/2. Pour que des clients HTTP/1.1 puissent appeler gRPC-Web, on autorise les deux protocoles (ce qui impose TLS sur le port pour la négociation ALPN) :
{
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http1AndHttp2"
}
}
}
Client navigateur en JavaScript
Le client officiel est le package npm grpc-web, accompagné du plugin protoc-gen-grpc-web qui génère le code client depuis le .proto :
npm install grpc-web google-protobuf
# Génère agenda_pb.js (messages) et agenda_grpc_web_pb.js (client)
protoc -I=. agenda.proto \
--js_out=import_style=commonjs:. \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:.
import { GetEventRequest } from './agenda_pb';
import { AgendaServiceClient } from './agenda_grpc_web_pb';
const client = new AgendaServiceClient('https://api.example.com');
const request = new GetEventRequest();
request.setId(1);
// Appel unary : callback (err, response)
client.getEvent(request, {}, (err, evt) => {
if (err) {
console.error(`Erreur gRPC ${err.code} : ${err.message}`);
return;
}
console.log(`${evt.getTitle()} - ${evt.getStartsAt()} (${evt.getRoom()})`);
});
Les messages générés utilisent des accesseurs (setId, getTitle) plutôt que des objets JSON : on reste dans le monde Protobuf, fortement typé, avec l’option de générer des définitions TypeScript.
Client Blazor WebAssembly
Une application Blazor WebAssembly tourne dans le navigateur et subit les mêmes contraintes. Le client gRPC .NET sait parler gRPC-Web grâce à GrpcWebHandler :
dotnet add package Grpc.Net.Client.Web
dotnet add package Grpc.Net.ClientFactory
using Grpc.Net.Client.Web;
using AgendaApi;
builder.Services
.AddGrpcClient<AgendaService.AgendaServiceClient>(options =>
{
options.Address = new Uri("https://api.example.com");
})
.ConfigurePrimaryHttpMessageHandler(() =>
// GrpcWebText : requis pour le server streaming dans le navigateur
new GrpcWebHandler(GrpcWebMode.GrpcWebText, new HttpClientHandler()));
Le composant injecte ensuite AgendaService.AgendaServiceClient comme n’importe quel service. Attention : en WebAssembly, seules les méthodes asynchrones (GetEventAsync) sont utilisables, les variantes synchrones bloquent l’application.
Limites du streaming
gRPC propose trois modes de streaming en plus de l’appel unary, mais ils ne sont que partiellement disponibles dans le navigateur :
| Mode | gRPC natif | gRPC-Web (navigateur) |
|---|---|---|
| Unary | ✅ | ✅ |
| Server streaming | ✅ | ✅ (mode grpc-web-text) |
| Client streaming | ✅ | ❌ |
| Bidirectionnel | ✅ | ❌ |
3. JSON transcoding
Le principe
Le JSON transcoding (package Microsoft.AspNetCore.Grpc.JsonTranscoding) génère, à partir d’annotations dans le .proto, des endpoints REST qui désérialisent le JSON en message Protobuf, appellent directement la méthode du service dans le même processus, puis sérialisent la réponse en JSON. Aucun proxy, aucune génération de client côté appelant.
sequenceDiagram
participant B as Navigateur
participant T as JSON transcoding
participant S as AgendaServiceImpl
B->>T: GET /v1/events/1
T->>T: route → GetEventRequest { id: 1 }
T->>S: GetEvent(request, context)
S-->>T: CalendarEvent (Protobuf)
T-->>B: 200 { "id": 1, "title": "Atelier Blazor", "room": "salle-a" }
Mise en place
dotnet add package Microsoft.AspNetCore.Grpc.JsonTranscoding
Dans le .csproj, la propriété IncludeHttpRuleProtos ajoute au build les fichiers google/api/annotations.proto et google/api/http.proto (auparavant, il fallait les copier à la main depuis le dépôt googleapis) :
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IncludeHttpRuleProtos>true</IncludeHttpRuleProtos>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\agenda.proto" GrpcServices="Server" />
</ItemGroup>
var builder = WebApplication.CreateBuilder(args);
// Un seul appel supplémentaire
builder.Services.AddGrpc().AddJsonTranscoding();
var app = builder.Build();
app.MapGrpcService<AgendaServiceImpl>();
app.Run();
Annoter le contrat
Chaque méthode exposée en REST reçoit une option google.api.http qui précise le verbe et la route :
syntax = "proto3";
option csharp_namespace = "AgendaApi";
import "google/api/annotations.proto";
package agenda;
service AgendaService {
// GET /v1/events/1 : {id} est lié au champ id de la requête
rpc GetEvent (GetEventRequest) returns (CalendarEvent) {
option (google.api.http) = {
get: "/v1/events/{id}"
};
}
// GET /v1/events?page=2 : les champs non liés à la route passent en query string
rpc ListEvents (ListEventsRequest) returns (EventList) {
option (google.api.http) = {
get: "/v1/events"
};
}
// POST /v1/events : le corps JSON entier est mappé sur la requête
rpc CreateEvent (CreateEventRequest) returns (CalendarEvent) {
option (google.api.http) = {
post: "/v1/events"
body: "*"
};
}
// Server streaming : réponse en JSON délimité par des retours à la ligne
rpc WatchRoom (WatchRoomRequest) returns (stream EventChange) {
option (google.api.http) = {
get: "/v1/rooms/{room}/changes"
};
}
}
message GetEventRequest { int32 id = 1; }
message ListEventsRequest { int32 page = 1; }
message CreateEventRequest {
string title = 1;
string starts_at = 2; // date ISO 8601, ex. 2026-05-12T09:00:00Z
string room = 3;
bool is_public = 4;
}
message CalendarEvent {
int32 id = 1;
string title = 2;
string starts_at = 3;
string room = 4;
bool is_public = 5;
}
message EventList { repeated CalendarEvent events = 1; }
message WatchRoomRequest { string room = 1; }
message EventChange {
int32 event_id = 1;
string change = 2; // "created", "moved", "cancelled"
}
Les méthodes sans annotation restent accessibles uniquement en gRPC. Les noms JSON suivent la convention Protobuf : is_public devient isPublic, starts_at devient startsAt.
Appeler l’API
# Lecture
curl https://localhost:5001/v1/events/1
# {"id":1,"title":"Atelier Blazor","startsAt":"2026-05-12T09:00:00Z","room":"salle-a","isPublic":true}
# Création
curl -X POST https://localhost:5001/v1/events \
-H "Content-Type: application/json" \
-d '{"title":"Meetup .NET","startsAt":"2026-06-02T18:30:00Z","room":"salle-b","isPublic":true}'
Depuis le navigateur, un simple fetch('/v1/events/1') suffit : ni client généré, ni connaissance de gRPC.
Quelques comportements à connaître :
- Erreurs : une
RpcExceptionest traduite en code HTTP (NotFound→ 404,InvalidArgument→ 400…) avec un corps JSON contenant le code gRPC et le message. - Streaming : seul le server streaming est supporté. Chaque message écrit par
WriteAsyncest sérialisé en JSON sur sa propre ligne (line-delimited JSON). Client streaming et bidirectionnel ne sont pas transcodés. - HTTP/1.1 : le transcoding fonctionne en HTTP/1.1 et HTTP/2 ; la configuration
Http1AndHttp2vue plus haut s’applique ici aussi. - Sérialisation :
AddJsonTranscoding(o => o.JsonSettings.WriteIndented = true)permet d’ajuster le JSON (IgnoreDefaultValues,WriteEnumsAsIntegers, etc.).
OpenAPI pour le transcoding
Une API REST sans documentation perd une partie de son intérêt. Le package Microsoft.AspNetCore.Grpc.Swagger branche le transcoding sur Swashbuckle : les routes google.api.http apparaissent dans le document OpenAPI, et les commentaires du .proto deviennent des descriptions.
dotnet add package Microsoft.AspNetCore.Grpc.Swagger
builder.Services.AddGrpc().AddJsonTranscoding();
// Rend les endpoints transcodés visibles par Swashbuckle
builder.Services.AddGrpcSwagger();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "Agenda", Version = "v1" });
});
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Agenda v1"));
app.MapGrpcService<AgendaServiceImpl>();
app.Run();
Attention : ce package est expérimental et dépend de Swashbuckle, pas du générateur OpenAPI natif d’ASP.NET Core (
Microsoft.AspNetCore.OpenApi). Les routes transcodées n’apparaissent pas dans le document produit parAddOpenApi(). Avant de s’appuyer dessus en production, il faut vérifier la compatibilité de la version disponible avec .NET 10 et la version de Swashbuckle utilisée.
4. gRPC-Web ou JSON transcoding ?
| Critère | gRPC-Web | JSON transcoding |
|---|---|---|
| Format | Protobuf (binaire ou base64) | JSON |
| Client | Généré (grpc-web, Blazor + GrpcWebHandler) |
Aucun : fetch, HttpClient, curl |
| Contrat côté client | .proto partagé, typage fort |
Implicite (ou OpenAPI) |
Modification du .proto |
Aucune | Annotations google.api.http |
| Server streaming | ✅ (grpc-web-text) |
✅ (JSON par ligne) |
| Client / bidi streaming | ❌ | ❌ |
| Performance réseau | Compacte | JSON plus verbeux |
| Consommateurs typiques | SPA ou Blazor de l’équipe | Partenaires, outils tiers, intégrations no-code |
| Debug | Outils spécifiques | Lisible dans les DevTools |
Les deux s’empilent sans conflit sur le même service :
builder.Services.AddGrpc().AddJsonTranscoding();
var app = builder.Build();
app.UseGrpcWeb();
app.UseCors();
app.MapGrpcService<AgendaServiceImpl>()
.EnableGrpcWeb()
.RequireCors("GrpcWeb");
En règle générale :
- gRPC-Web quand le front est maîtrisé par l’équipe (Blazor WebAssembly en particulier) : on garde le contrat
.proto, le typage et la compacité de bout en bout. - JSON transcoding quand l’API doit être consommée par des tiers ou des outils qui attendent du REST : un seul service, deux façades, sans maintenir un contrôleur REST en double.
- gRPC natif entre services, où ni l’une ni l’autre n’est nécessaire.
Résumé
| Sujet | Package / API | À retenir |
|---|---|---|
| gRPC-Web serveur | Grpc.AspNetCore.Web, UseGrpcWeb(), EnableGrpcWeb() |
Aucune modification du service |
| CORS | AddCors, WithExposedHeaders("Grpc-Status", …), RequireCors |
Exposer les headers gRPC |
| Client JavaScript | npm grpc-web + protoc-gen-grpc-web |
Unary et server streaming uniquement |
| Client Blazor WASM | Grpc.Net.Client.Web, GrpcWebHandler |
GrpcWebText pour le streaming, appels async seulement |
| JSON transcoding | Microsoft.AspNetCore.Grpc.JsonTranscoding, AddJsonTranscoding() |
IncludeHttpRuleProtos + google.api.http |
| OpenAPI | Microsoft.AspNetCore.Grpc.Swagger + Swashbuckle |
Expérimental, hors AddOpenApi() natif |
| HTTP/1.1 | Protocols: Http1AndHttp2 |
TLS requis sur le port partagé |
gRPC-Web et le JSON transcoding lèvent le principal frein de gRPC hors du datacenter : son inaccessibilité depuis le navigateur. Le premier prolonge le contrat Protobuf jusqu’au front, le second offre une façade REST gratuite au-dessus des mêmes services. Combinés, ils permettent d’écrire la logique une seule fois et de l’exposer à chaque consommateur dans le format qui lui convient.