@nodefony/framework 10.0.0-beta.6 → 10.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- package/dist/nodefony/controller/CspReportController.js +2 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +2 -2
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +74 -9
- package/dist/nodefony/controller/SessionAuthController.js +1 -1
- package/dist/nodefony/decorators/routerDecorators.js +101 -13
- package/dist/nodefony/grpc/GrpcController.js +138 -0
- package/dist/nodefony/grpc/GrpcService.js +75 -0
- package/dist/nodefony/grpc/declarations.js +33 -0
- package/dist/nodefony/grpc/index.js +3 -0
- package/dist/nodefony/service/AdminBroker.js +1 -1
- package/dist/nodefony/service/IdempotencyStore.js +1 -1
- package/dist/nodefony/service/router.js +11 -9
- package/dist/nodefony/src/Controller.js +21 -3
- package/dist/nodefony/src/Resolver.js +83 -43
- package/dist/nodefony/src/Route.js +45 -45
- package/dist/nodefony/src/loginPage.js +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +21 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +2 -1
- package/dist/types/nodefony/grpc/GrpcController.d.ts +75 -0
- package/dist/types/nodefony/grpc/GrpcService.d.ts +51 -0
- package/dist/types/nodefony/grpc/declarations.d.ts +12 -0
- package/dist/types/nodefony/grpc/index.d.ts +13 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
- package/dist/types/nodefony/service/router.d.ts +5 -4
- package/dist/types/nodefony/src/Controller.d.ts +15 -3
- package/dist/types/nodefony/src/Resolver.d.ts +34 -7
- package/dist/types/nodefony/src/Route.d.ts +35 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/docs/controller.md +89 -9
- package/docs/grpc.md +690 -0
- package/docs/idempotence.md +11 -6
- package/docs/index.md +12 -8
- package/docs/routing.md +30 -2
- package/package.json +24 -8
package/docs/grpc.md
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "gRPC — servir un service déclaré par un module"
|
|
3
|
+
navTitle: gRPC
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/framework"
|
|
6
|
+
topic: grpc
|
|
7
|
+
section: "Cœur runtime"
|
|
8
|
+
audience: [developer, devops]
|
|
9
|
+
tags: [grpc, grpc-web, connect, protobuf, http2, rpc, controller, streaming]
|
|
10
|
+
version: "doc"
|
|
11
|
+
status: experimental
|
|
12
|
+
updated: 2026-11-13
|
|
13
|
+
source: "src/packages/@nodefony/framework/docs/grpc.md"
|
|
14
|
+
coverageModule: framework
|
|
15
|
+
coverageFiles: GrpcController.ts,GrpcService.ts,GrpcEndpoint.ts,GrpcCall.ts,refusal.ts
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# gRPC — servir un service déclaré par un module
|
|
19
|
+
|
|
20
|
+
> Un service gRPC se **déclare** comme une route : une classe `GrpcController` marquée
|
|
21
|
+
> `@GrpcService(Contrat)`, listée dans `@controllers([...])`. Nodefony le sert sur les ports
|
|
22
|
+
> qu'il ouvre déjà, sous le pare-feu et `@IsGranted`, en gRPC, gRPC-Web et Connect à la fois.
|
|
23
|
+
> Cette page montre comment écrire le contrat et le contrôleur, puis ce que le framework décide à
|
|
24
|
+
> ta place — tailles, échéances, refus, erreurs, arrêt, CORS, journal — et **pourquoi**.
|
|
25
|
+
> Surface **expérimentale** : sa forme peut encore changer en version mineure.
|
|
26
|
+
|
|
27
|
+
📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **gRPC**
|
|
28
|
+
|
|
29
|
+
## 🧠 Schéma général
|
|
30
|
+
|
|
31
|
+
```mermaid
|
|
32
|
+
flowchart LR
|
|
33
|
+
G["gRPC<br/>service · grpcurl"] --> P
|
|
34
|
+
W["gRPC-Web<br/>navigateur"] --> P
|
|
35
|
+
C["Connect<br/>navigateur · curl"] --> P
|
|
36
|
+
P["Nodefony<br/>pare-feu · droits"] --> A["adaptateur Connect<br/>lit le content-type"]
|
|
37
|
+
A --> M["ta méthode<br/>une seule fois"]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Trois protocoles arrivent sur la même route : **gRPC** (un autre service, `grpcurl`, HTTP/2
|
|
41
|
+
obligatoire), **gRPC-Web** (un navigateur, qui n'a pas accès aux trailers) et **Connect** (un
|
|
42
|
+
navigateur ou `curl`, en JSON ou en binaire). Tu écris ta méthode une fois, elle répond aux trois.
|
|
43
|
+
|
|
44
|
+
Un appel gRPC traverse **le même pipeline** qu'une requête HTTP ordinaire : une méthode gRPC
|
|
45
|
+
**est** une route. Seule la dernière étape change : au lieu d'appeler une action, le contrôleur
|
|
46
|
+
remet la requête à l'adaptateur Connect. Celui-ci reconnaît le protocole au `content-type`,
|
|
47
|
+
décode le message, appelle ta méthode et écrit la réponse dans le format du client.
|
|
48
|
+
|
|
49
|
+
## 📖 Lexique
|
|
50
|
+
|
|
51
|
+
| Terme | Développé | En clair |
|
|
52
|
+
| ----------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| **RPC** | _Remote Procedure Call_ | appeler une fonction d'un autre programme comme si elle était locale |
|
|
54
|
+
| **gRPC** | _gRPC Remote Procedure Calls_ | le protocole RPC de la CNCF : messages protobuf sur HTTP/2, statut dans les _trailers_ |
|
|
55
|
+
| **gRPC-Web** | — | variante de gRPC lisible par un navigateur : statut dans le corps, HTTP/1.1 accepté |
|
|
56
|
+
| **Connect** | protocole Connect | variante HTTP simple (JSON ou binaire, `curl` suffit) ; aussi le nom de la bibliothèque `@connectrpc/connect` qui sert les trois |
|
|
57
|
+
| **protobuf** | _Protocol Buffers_ | le langage de description des messages et le format binaire qui les encode |
|
|
58
|
+
| **contrat** | fichier `.proto` | la liste des services, méthodes et messages ; source unique du client et du serveur |
|
|
59
|
+
| **buf** | outil `buf` | compile, vérifie (`lint`, `breaking`) et génère le code d'un contrat |
|
|
60
|
+
| **code généré** | `*_pb.ts` | le TypeScript que `buf generate` tire du contrat : descripteurs et types des messages |
|
|
61
|
+
| **unaire** | _unary_ | un message envoyé, un message reçu |
|
|
62
|
+
| **flux** | _streaming_ | plusieurs messages : du serveur, du client, ou des deux (bidirectionnel) |
|
|
63
|
+
| **trailers** | en-têtes de fin | en-têtes envoyés APRÈS le corps ; gRPC y place `grpc-status` |
|
|
64
|
+
| **Trailers-Only** | — | réponse gRPC sans corps, dont les en-têtes portent déjà le statut et la fin du flux |
|
|
65
|
+
| **échéance** | _deadline_ | délai qu'un client accorde à un appel (en-tête `grpc-timeout`) ; dépassé, statut 4 |
|
|
66
|
+
| **h2c** | HTTP/2 _cleartext_ | HTTP/2 sans TLS, pour une passerelle de confiance (port 5153, désactivé par défaut) |
|
|
67
|
+
| **ALS** | `AsyncLocalStorage` | le contexte de la requête (identité, `requestId`) qui suit le code asynchrone |
|
|
68
|
+
| **CORS** | _Cross-Origin Resource Sharing_ | ce qu'un navigateur d'une autre origine a le droit d'appeler et de lire |
|
|
69
|
+
| **CSRF** | _Cross-Site Request Forgery_ | un site tiers fait envoyer une requête par le navigateur de la victime à son insu |
|
|
70
|
+
| **OTel** | OpenTelemetry | conventions de nommage des traces et métriques |
|
|
71
|
+
|
|
72
|
+
## Qu'est-ce que c'est ?
|
|
73
|
+
|
|
74
|
+
gRPC, c'est **un appel de fonction à travers le réseau, avec un contrat signé des deux côtés**.
|
|
75
|
+
Le contrat (`.proto`) dit exactement quelles méthodes existent et quels messages elles prennent ;
|
|
76
|
+
un outil en tire le code du client et du serveur, dans n'importe quel langage. Les deux parties ne
|
|
77
|
+
peuvent pas se tromper de forme : le compilateur les en empêche.
|
|
78
|
+
|
|
79
|
+
On le choisit pour trois raisons :
|
|
80
|
+
|
|
81
|
+
- **entre services** : un contrat typé, un format binaire compact, et des flux dans les deux sens
|
|
82
|
+
sur une seule connexion HTTP/2 ;
|
|
83
|
+
- **pour des agents** : A2A (_Agent2Agent_) publie une interface gRPC ;
|
|
84
|
+
- **pour des clients hétérogènes** : un service Go, un outil `grpcurl`, un navigateur (par
|
|
85
|
+
gRPC-Web ou Connect) appellent la même méthode.
|
|
86
|
+
|
|
87
|
+
## La vision Nodefony
|
|
88
|
+
|
|
89
|
+
**Un service gRPC est un point d'entrée du module, comme une route ou un contrôleur temps réel.**
|
|
90
|
+
Trois choix en découlent, chacun avec son pourquoi.
|
|
91
|
+
|
|
92
|
+
1. **Le pipeline, pas un second serveur.** La bibliothèque de référence `@grpc/grpc-js` ouvre son
|
|
93
|
+
propre serveur : ni pare-feu, ni `@IsGranted`, ni gRPC-Web. Nodefony emploie Connect
|
|
94
|
+
(`@connectrpc/connect-node`, Apache-2.0, aucune dépendance transitive), la seule bibliothèque
|
|
95
|
+
qui fournit une fonction `(requête, réponse)` branchable dans une route. Chaque méthode devient
|
|
96
|
+
une route littérale du Router, et **une seule autorisation** sert HTTP, WebSocket et gRPC
|
|
97
|
+
(`Resolver.ts#Resolver._enforceSecurity`).
|
|
98
|
+
2. **Un contrôleur dédié, la logique dans un service.** `GrpcController` est le jumeau de
|
|
99
|
+
`RealtimeController` : le développeur cherche ce qu'un module expose dans `@controllers`, il le
|
|
100
|
+
trouve là. Les gardes se lisent sur la méthode du contrat (`GrpcController.ts#GrpcController.assertScope`).
|
|
101
|
+
3. **Le statut se choisit sur l'identité.** Un refus sans identité part en 16
|
|
102
|
+
`UNAUTHENTICATED`, un refus d'un appelant identifié en 7 `PERMISSION_DENIED`, quel que soit le
|
|
103
|
+
statut HTTP. Avec 16, un agent rafraîchit son jeton et se reconnecte ; avec 7, il abandonne
|
|
104
|
+
(`refusal.ts#transportRefusalCode`).
|
|
105
|
+
|
|
106
|
+
Le compromis assumé : chaque appel paie le pipeline (≈ 24 µs de code Nodefony, mesurés plus bas)
|
|
107
|
+
en échange du pare-feu, de l'ALS, des en-têtes de sécurité, du journal et de la trace — les mêmes
|
|
108
|
+
que sur toute route. Les décisions et leurs essais sont tracés dans
|
|
109
|
+
[ADR-0026](../../../../../docs/adr/0026-service-grpc-declare-par-un-module.md).
|
|
110
|
+
|
|
111
|
+
## 🚀 Démarrage rapide
|
|
112
|
+
|
|
113
|
+
Un service `Greet` dans une application générée par `nodefony create app`, appelé par `curl` et
|
|
114
|
+
par `grpcurl`.
|
|
115
|
+
|
|
116
|
+
**1. Les paquets, seulement si tu sers un service gRPC.** Nodefony ne les installe pas et ne les
|
|
117
|
+
exige pas : une application sans service gRPC n'en a besoin d'aucun, et rien ne se charge.
|
|
118
|
+
Pour servir un service, ajoute le runtime en dépendances (l'installation de production doit
|
|
119
|
+
l'avoir) et le générateur en développement :
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm i @connectrpc/connect @connectrpc/connect-node @bufbuild/protobuf
|
|
123
|
+
npm i -D @bufbuild/buf @bufbuild/protoc-gen-es
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**2. Le contrat**, `proto/greet/v1/greet.proto` à la racine de l'application :
|
|
127
|
+
|
|
128
|
+
```proto
|
|
129
|
+
edition = "2024";
|
|
130
|
+
|
|
131
|
+
package greet.v1;
|
|
132
|
+
|
|
133
|
+
service GreetService {
|
|
134
|
+
rpc Greet(GreetRequest) returns (GreetResponse);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
message GreetRequest {
|
|
138
|
+
string name = 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
message GreetResponse {
|
|
142
|
+
string greeting = 1;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**3. La génération**, deux fichiers à côté de `proto/` :
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
# buf.yaml
|
|
150
|
+
version: v2
|
|
151
|
+
modules:
|
|
152
|
+
- path: proto
|
|
153
|
+
lint:
|
|
154
|
+
use: [STANDARD]
|
|
155
|
+
breaking:
|
|
156
|
+
use: [FILE]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
# buf.gen.yaml
|
|
161
|
+
version: v2
|
|
162
|
+
inputs:
|
|
163
|
+
- directory: proto
|
|
164
|
+
plugins:
|
|
165
|
+
- local: protoc-gen-es
|
|
166
|
+
out: nodefony/gen
|
|
167
|
+
opt: target=ts
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx buf generate # écrit nodefony/gen/greet/v1/greet_pb.ts — commité, jamais édité
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**4. Le contrôleur**, `nodefony/controllers/GreetController.ts`. Chaque méthode du contrat est
|
|
175
|
+
une méthode de la classe ; une méthode manquante ou mal typée ne compile pas.
|
|
176
|
+
|
|
177
|
+
<!-- example: GreetController.ts -->
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { GrpcController, GrpcService } from "@nodefony/framework/grpc";
|
|
181
|
+
import type { ContextType } from "@nodefony/http";
|
|
182
|
+
import { GreetService } from "../gen/greet/v1/greet_pb";
|
|
183
|
+
import type { GreetRequest } from "../gen/greet/v1/greet_pb";
|
|
184
|
+
|
|
185
|
+
@GrpcService(GreetService)
|
|
186
|
+
export class GreetController extends GrpcController {
|
|
187
|
+
constructor(context: ContextType) {
|
|
188
|
+
super("GreetController", context);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
greet(request: GreetRequest) {
|
|
192
|
+
return { greeting: `Bonjour, ${request.name} !` };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**5. La déclaration**, dans `index.ts` de l'application, à côté des autres contrôleurs :
|
|
198
|
+
|
|
199
|
+
```ts ignore
|
|
200
|
+
import { GreetController } from "./nodefony/controllers/GreetController";
|
|
201
|
+
|
|
202
|
+
@controllers([HomeController, GreetController])
|
|
203
|
+
class App extends Module {
|
|
204
|
+
// …
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**6. Ce qu'on observe**, application démarrée (`npx nodefony development`) :
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
# Connect en JSON, HTTP/1.1 — curl suffit
|
|
212
|
+
curl -s http://127.0.0.1:5151/greet.v1.GreetService/Greet \
|
|
213
|
+
-H 'content-type: application/json' -d '{"name":"Ada"}'
|
|
214
|
+
# → {"greeting":"Bonjour, Ada !"}
|
|
215
|
+
|
|
216
|
+
# gRPC natif, HTTP/2 + TLS (certificat de développement)
|
|
217
|
+
grpcurl -insecure -import-path proto -proto greet/v1/greet.proto \
|
|
218
|
+
-d '{"name":"Ada"}' 127.0.0.1:5152 greet.v1.GreetService/Greet
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
La ligne d'accès nomme l'appel : `POST /greet.v1.GreetService/Greet … RPC : greet.v1.GreetService/Greet`.
|
|
222
|
+
Le contrat et le contrôleur de cette page sont les fichiers d'un test du framework, qui monte la
|
|
223
|
+
route et appelle la méthode (`grpcQuickstart.test.ts`) : la page ne peut pas dériver du code.
|
|
224
|
+
|
|
225
|
+
## 🧰 Écrire un service
|
|
226
|
+
|
|
227
|
+
### Les quatre formes de méthode
|
|
228
|
+
|
|
229
|
+
La signature vient du code généré ; Nodefony n'y ajoute rien.
|
|
230
|
+
|
|
231
|
+
```ts ignore
|
|
232
|
+
@GrpcService(ChatService)
|
|
233
|
+
export class ChatController extends GrpcController {
|
|
234
|
+
// Unaire : un message, une réponse (ou une promesse de réponse).
|
|
235
|
+
send(request: SendRequest, ctx: HandlerContext) {
|
|
236
|
+
return { id: this.chat.post(request.text) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Flux serveur : un générateur asynchrone. Passer ctx.signal aux sources.
|
|
240
|
+
async *follow(request: FollowRequest, ctx: HandlerContext) {
|
|
241
|
+
for await (const message of this.chat.subscribe(request.room, ctx.signal)) {
|
|
242
|
+
yield { text: message };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Flux bidirectionnel : on lit pendant qu'on écrit.
|
|
247
|
+
async *converse(requests: AsyncIterable<ConverseRequest>) {
|
|
248
|
+
for await (const request of requests) yield { echo: request.text };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`ctx` est le `HandlerContext` de Connect : en-têtes reçus (`ctx.requestHeader`), en-têtes et
|
|
254
|
+
trailers de réponse, signal d'annulation (`ctx.signal`), échéance du client (`ctx.timeoutMs()`).
|
|
255
|
+
|
|
256
|
+
### La logique dans un service injecté
|
|
257
|
+
|
|
258
|
+
Le contrôleur traduit ; le travail vit dans un service, partagé avec les routes HTTP et le temps
|
|
259
|
+
réel. L'injection est celle de tout contrôleur.
|
|
260
|
+
|
|
261
|
+
```ts ignore
|
|
262
|
+
@GrpcService(GreetService)
|
|
263
|
+
export class GreetController extends GrpcController {
|
|
264
|
+
constructor(
|
|
265
|
+
context: ContextType,
|
|
266
|
+
@Inject("greeter") private readonly greeter: Greeter,
|
|
267
|
+
) {
|
|
268
|
+
super("GreetController", context);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
greet(request: GreetRequest) {
|
|
272
|
+
return { greeting: this.greeter.greet(request.name) };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Protéger une méthode
|
|
278
|
+
|
|
279
|
+
Les décorateurs de sécurité de toute route s'appliquent, posés sur la méthode du contrat ou sur la
|
|
280
|
+
classe. L'identité se lit par `this.user`, comme dans toute action.
|
|
281
|
+
|
|
282
|
+
```ts ignore
|
|
283
|
+
@GrpcService(AccountService)
|
|
284
|
+
export class AccountController extends GrpcController {
|
|
285
|
+
@IsGranted("ROLE_ADMIN")
|
|
286
|
+
close(request: CloseRequest) {
|
|
287
|
+
return { closedBy: this.user?.identifier ?? "" };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Un appel sans jeton reçoit 16, un jeton valide sans le rôle reçoit 7 — section
|
|
293
|
+
[Refus et codes](#-refus-et-codes).
|
|
294
|
+
|
|
295
|
+
### Ce que la déclaration engage
|
|
296
|
+
|
|
297
|
+
- **Une route par méthode** : `POST /<paquet.Service>/<Méthode>`, plus `GET` pour une méthode
|
|
298
|
+
`option idempotency_level = NO_SIDE_EFFECTS` (le GET de Connect) — `GrpcService.ts#GrpcService`.
|
|
299
|
+
Elles apparaissent dans la liste des routes, le profileur, les hôtes virtuels.
|
|
300
|
+
- **Singleton par défaut** : une instance pour tous les appels ; `@Scope("request")` en donne une
|
|
301
|
+
par appel.
|
|
302
|
+
- **`@Domain` et `@BypassFirewall`** se placent SOUS `@GrpcService`, lus au montage des routes.
|
|
303
|
+
- **Le démarrage refuse**, en nommant la méthode : une classe sans `@GrpcService`, une méthode du
|
|
304
|
+
contrat absente de la classe, un nom réservé par le framework (`serveGrpc`…), un contrat servi par
|
|
305
|
+
deux classes, et un décorateur sans effet en gRPC — `@Body`/`@Param`/`@Query`, schéma de route,
|
|
306
|
+
`@UseSession`, `@Csp`, `@Idempotent`, `@HttpCode`, `@Header`, `@Redirect`
|
|
307
|
+
(`GrpcController.ts#GrpcController.assertScope`). Un décorateur muet laisserait croire à une
|
|
308
|
+
protection qui n'existe pas.
|
|
309
|
+
|
|
310
|
+
### Où vivent le contrat et le code généré
|
|
311
|
+
|
|
312
|
+
Une convention, jamais une obligation : Nodefony ne lit aucun de ces fichiers, le contrôleur
|
|
313
|
+
importe le code généré par un import ordinaire. Identique dans une application et dans un module.
|
|
314
|
+
|
|
315
|
+
| Chemin | Contenu | Pourquoi |
|
|
316
|
+
| --------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
317
|
+
| `proto/<paquet>/v1/*.proto` | le contrat public | hors du bundler, lisible par les clients d'autres langages |
|
|
318
|
+
| `buf.yaml`, `buf.gen.yaml` | lint, compatibilité, génération | `STANDARD` (dossier = paquet versionné) et `FILE`, la règle de compatibilité la plus stricte |
|
|
319
|
+
| `nodefony/gen/**/*_pb.ts` | le code généré, **commité** | Nodefony ne génère rien au build ni au démarrage ; une installation de production n'a pas `buf` |
|
|
320
|
+
|
|
321
|
+
Pour un contrat neuf, `edition = "2024"` ; `proto3` est accepté. Les types bien connus
|
|
322
|
+
(`google/protobuf/*`) viennent de `@bufbuild/protobuf/wkt`, jamais régénérés. Un contrat tiers non
|
|
323
|
+
publié sur le registre buf (A2A) se génère depuis son dépôt, version épinglée :
|
|
324
|
+
`inputs: - git_repo: …, tag: v1.0.1, subdir: specification`.
|
|
325
|
+
|
|
326
|
+
## 🔌 Protocoles et ports
|
|
327
|
+
|
|
328
|
+
**Aucun serveur ni port neuf.** La méthode répond sur chaque serveur actif, avec ce que son HTTP
|
|
329
|
+
permet ; le protocole se reconnaît au `content-type`, dans le même gestionnaire.
|
|
330
|
+
|
|
331
|
+
| Port | HTTP | gRPC | gRPC-Web | Connect |
|
|
332
|
+
| --------------------- | --------------- | :--: | :------: | :-----: |
|
|
333
|
+
| 5152 | HTTP/2 + TLS | ✅ | ✅ | ✅ |
|
|
334
|
+
| 5153 (h2c, à activer) | HTTP/2 en clair | ✅ | ✅ | ✅ |
|
|
335
|
+
| 5151 | HTTP/1.1 | ❌ | ✅ | ✅ |
|
|
336
|
+
|
|
337
|
+
- **gRPC natif exige HTTP/2.** Un client gRPC qui vise 5151 échoue vite et franchement
|
|
338
|
+
(`ERR_HTTP2_ERROR` en une quinzaine de millisecondes, lu 13 `INTERNAL`), jamais à son échéance.
|
|
339
|
+
- **h2c n'est jamais ouvert par la déclaration d'un service** : seul `servers.h2c.enabled: true`
|
|
340
|
+
l'allume, et il ne sert que derrière une passerelle de confiance (A2A exige TLS en production) —
|
|
341
|
+
voir [Serveurs](../../http/docs/servers.md).
|
|
342
|
+
- **Un service qui ne doit jamais passer en clair** l'exige par le mécanisme de toute route,
|
|
343
|
+
`requirements.scheme`.
|
|
344
|
+
|
|
345
|
+
**Mettre en situation.**
|
|
346
|
+
|
|
347
|
+
| Ton besoin | Ce que tu fais | Ce qui se passe |
|
|
348
|
+
| --------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------- |
|
|
349
|
+
| Un service Go appelle ton pod derrière un proxy qui termine TLS | `servers.h2c.enabled: true`, le proxy en h2c vers 5153 | gRPC natif servi en clair sur le lien interne |
|
|
350
|
+
| Une page web appelle la même méthode | rien | gRPC-Web ou Connect sur 5151 ou 5152 |
|
|
351
|
+
| Interdire gRPC-Web à toute l'application | `grpc: { grpcWeb: false }` | la méthode ne répond plus qu'en gRPC et Connect |
|
|
352
|
+
|
|
353
|
+
### Le navigateur ne rouvre pas le CSRF
|
|
354
|
+
|
|
355
|
+
Un POST Connect exige `application/json` ou `application/proto` (415 sinon) : un formulaire ou un
|
|
356
|
+
`text/plain` d'une autre origine est refusé. Un POST JSON d'une autre origine déclenche un
|
|
357
|
+
contrôle préalable CORS. Le `GET` n'existe que pour une méthode sans effet de bord. D'où
|
|
358
|
+
`requireConnectProtocolHeader: false` par défaut : exiger l'en-tête casserait `curl` et le JSON
|
|
359
|
+
écrit à la main sans fermer une faille de plus.
|
|
360
|
+
|
|
361
|
+
## ⚙️ Configuration
|
|
362
|
+
|
|
363
|
+
La section `grpc` de la configuration de `@nodefony/http` vaut pour **toute l'application** ; le
|
|
364
|
+
grain « par service » s'ajoutera sans rupture s'il trouve un usage (`http/nodefony/config/config.ts#grpcSchema`).
|
|
365
|
+
|
|
366
|
+
```ts ignore
|
|
367
|
+
// nodefony.config.ts
|
|
368
|
+
use("@nodefony/http", {
|
|
369
|
+
grpc: { grpcWeb: false, readMaxBytes: 8 * 1024 * 1024 },
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
| Clé | Type | Défaut | Effet |
|
|
374
|
+
| ------------------------------ | ------- | ------- | ---------------------------------------------------------- |
|
|
375
|
+
| `grpc` | booléen | `true` | sert gRPC (HTTP/2 seulement) |
|
|
376
|
+
| `grpcWeb` | booléen | `true` | sert gRPC-Web, sur tous les serveurs |
|
|
377
|
+
| `connect` | booléen | `true` | sert Connect, `curl` compris |
|
|
378
|
+
| `readMaxBytes` | entier | 4 Mio | taille maximale d'un message **reçu**, après décompression |
|
|
379
|
+
| `writeMaxBytes` | entier | 4 Mio | taille maximale d'un message **émis** |
|
|
380
|
+
| `requireConnectProtocolHeader` | booléen | `false` | exige `Connect-Protocol-Version` (400 sans lui) |
|
|
381
|
+
|
|
382
|
+
Au moins un protocole doit rester servi : pour n'en servir aucun, ne déclarer aucun service. Une
|
|
383
|
+
clé mal orthographiée interrompt le démarrage en la nommant.
|
|
384
|
+
|
|
385
|
+
## Limites : taille, durée, flux
|
|
386
|
+
|
|
387
|
+
| Limite | Valeur | Au-delà | Pourquoi |
|
|
388
|
+
| ------------------------------ | ------------------------------------------ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
389
|
+
| message reçu | 4 Mio (`readMaxBytes`) | 8 `RESOURCE_EXHAUSTED`, méthode non appelée | sans borne, quelques Ko compressés se décompressent en plusieurs Gio ; 4 Mio est la borne des implémentations de référence |
|
|
390
|
+
| message émis | 4 Mio (`writeMaxBytes`) | 8, journalisé chez nous | les clients de référence refusent au-delà : l'erreur naît où on la lit |
|
|
391
|
+
| appel unaire | `min(échéance du client, responseTimeout)` | 4 `DEADLINE_EXCEEDED` | même délai que toute route, au format du protocole |
|
|
392
|
+
| flux | aucune échéance | — | un abonnement vit longtemps, comme le veut gRPC |
|
|
393
|
+
| client de flux qui ne lit plus | 30 s de blocage | coupé entre 30 et 45 s | sinon un client lent retient la mémoire du serveur |
|
|
394
|
+
|
|
395
|
+
- **`http.maxBodySize` ne borne pas une route gRPC** : son corps est lu en flux par l'adaptateur,
|
|
396
|
+
et seule sa borne par message s'applique.
|
|
397
|
+
- **Le délai du serveur n'est pas écrit dans `grpc-timeout`.** `ctx.timeoutMs()` rend l'échéance
|
|
398
|
+
envoyée par le client, et elle seule ; le délai de Nodefony termine l'appel lui-même, même si la
|
|
399
|
+
méthode n'écoute pas son signal (`GrpcCall.ts#GrpcCall.expire`).
|
|
400
|
+
- **Un client qui demande plus long n'est pas refusé** : son échéance est rabotée par le délai du
|
|
401
|
+
serveur, jamais rejetée en 3.
|
|
402
|
+
- **Un flux** prend une place dans le budget de connexions longues par IP, comme SSE et
|
|
403
|
+
WebSocket, et il est fermé si l'identité qui l'a ouvert est révoquée (16, dans la fenêtre de
|
|
404
|
+
revalidation de 30 s).
|
|
405
|
+
|
|
406
|
+
## 🔐 Refus et codes
|
|
407
|
+
|
|
408
|
+
Avant ta méthode, le pipeline refuse dans l'ordre de toute route : provenance CSRF, session, zone
|
|
409
|
+
du pare-feu, puis `@IsGranted`. **Le noyau écrit le refus au format du client**, sans construire
|
|
410
|
+
l'adaptateur (`refusal.ts#prepareGrpcRefusal`).
|
|
411
|
+
|
|
412
|
+
| Situation | Code |
|
|
413
|
+
| ---------------------------------------------------------------------- | --------------------- |
|
|
414
|
+
| refus, aucune identité authentifiée (un anonyme compte comme personne) | 16 `UNAUTHENTICATED` |
|
|
415
|
+
| refus, appelant identifié | 7 `PERMISSION_DENIED` |
|
|
416
|
+
|
|
417
|
+
- **Pourquoi pas le statut HTTP** : `@IsGranted` répond 403 même sans identité, or la norme gRPC
|
|
418
|
+
interdit `PERMISSION_DENIED` quand l'appelant n'est pas identifié.
|
|
419
|
+
- **Le message est le libellé générique du code**, jamais le texte de l'erreur, qui nommait la
|
|
420
|
+
zone refusée. Le détail va au journal, avec le `requestId`.
|
|
421
|
+
- **Un client gRPC ne reçoit jamais de redirection** vers une page de connexion : elle exige une
|
|
422
|
+
navigation de navigateur.
|
|
423
|
+
- **Un client natif passe le CSRF** : il n'envoie ni `Origin` ni `Sec-Fetch-Site` ; un appel
|
|
424
|
+
d'une autre origine par navigateur est refusé.
|
|
425
|
+
|
|
426
|
+
### Refus avant ta route : chemin inconnu, débit, hôte
|
|
427
|
+
|
|
428
|
+
Un refus de **transport** (aucune route, méthode HTTP refusée, débit, hôte) suit la
|
|
429
|
+
correspondance normative des statuts HTTP, distincte de celle des erreurs de ton application
|
|
430
|
+
(`refusal.ts#transportRefusalCode`) :
|
|
431
|
+
|
|
432
|
+
| HTTP | gRPC |
|
|
433
|
+
| ----------------------- | ------------------------- |
|
|
434
|
+
| 400 | 13 `INTERNAL` |
|
|
435
|
+
| 401, 403 | 16 ou 7, selon l'identité |
|
|
436
|
+
| 404, 405 | 12 `UNIMPLEMENTED` |
|
|
437
|
+
| 421, 429, 502, 503, 504 | 14 `UNAVAILABLE` |
|
|
438
|
+
| autre | 2 `UNKNOWN` |
|
|
439
|
+
|
|
440
|
+
405 et 421 manquent à la table normative ; Nodefony les ajoute (aucune méthode gRPC à ce chemin ;
|
|
441
|
+
réessayer sur une autre connexion). En protocole Connect, le statut HTTP voyage avec la réponse :
|
|
442
|
+
le code est alors celui que le client en déduit (405 et 421 → `unknown`), comme la spécification
|
|
443
|
+
de Connect le veut.
|
|
444
|
+
|
|
445
|
+
### La forme écrite, par protocole
|
|
446
|
+
|
|
447
|
+
| Protocole | Statut HTTP | Corps |
|
|
448
|
+
| ---------------------------------- | -------------------------- | --------------------------------------- |
|
|
449
|
+
| gRPC (`application/grpc*`) | 200, _Trailers-Only_ | aucun |
|
|
450
|
+
| gRPC-Web (`application/grpc-web*`) | 200, _Trailers-Only_ | aucun |
|
|
451
|
+
| Connect unaire | celui du refus (401, 403…) | `{"code":"unauthenticated"}` |
|
|
452
|
+
| Connect en flux | 200 | message de fin `{"error":{"code":"…"}}` |
|
|
453
|
+
|
|
454
|
+
> [!IMPORTANT]
|
|
455
|
+
> Un 404 ou un 405 ne lit plus le corps de la requête, pour toute route : un client de flux qui
|
|
456
|
+
> attend une réponse avant de finir son envoi (`grpcurl list` sur un chemin inconnu) n'est plus
|
|
457
|
+
> bloqué jusqu'à son échéance.
|
|
458
|
+
|
|
459
|
+
## Erreurs levées pendant un appel
|
|
460
|
+
|
|
461
|
+
| Ce que ta méthode lève | Ce que reçoit le client |
|
|
462
|
+
| ----------------------------------------------------- | ----------------------------------------------------------------- |
|
|
463
|
+
| `ConnectError` | son code et son message, tels quels |
|
|
464
|
+
| `HttpError` d'un service partagé avec des routes HTTP | le code de la table ci-dessous |
|
|
465
|
+
| toute autre exception | 13 `INTERNAL` ; message en développement, générique en production |
|
|
466
|
+
|
|
467
|
+
Table des erreurs de l'application, celle de `google/rpc/code.proto`
|
|
468
|
+
(`refusal.ts#applicationErrorCode`) :
|
|
469
|
+
|
|
470
|
+
| HTTP | gRPC |
|
|
471
|
+
| --------- | ---------------------------------------------------------- |
|
|
472
|
+
| 400, 422 | 3 `INVALID_ARGUMENT` |
|
|
473
|
+
| 401, 403 | 16 ou 7, selon l'identité |
|
|
474
|
+
| 404 | 5 `NOT_FOUND` |
|
|
475
|
+
| 409 | 10 `ABORTED` (`ALREADY_EXISTS` : lever une `ConnectError`) |
|
|
476
|
+
| 412 | 9 `FAILED_PRECONDITION` |
|
|
477
|
+
| 413, 429 | 8 `RESOURCE_EXHAUSTED` |
|
|
478
|
+
| 499 | 1 `CANCELLED` |
|
|
479
|
+
| 501 | 12 `UNIMPLEMENTED` |
|
|
480
|
+
| 502, 503 | 14 `UNAVAILABLE` |
|
|
481
|
+
| 408, 504 | 4 `DEADLINE_EXCEEDED` |
|
|
482
|
+
| autre 5xx | 13 `INTERNAL` |
|
|
483
|
+
| autre | 2 `UNKNOWN` |
|
|
484
|
+
|
|
485
|
+
```ts ignore
|
|
486
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
487
|
+
|
|
488
|
+
reserve(request: ReserveRequest) {
|
|
489
|
+
if (this.stock.isEmpty(request.sku)) {
|
|
490
|
+
throw new ConnectError("rupture de stock", Code.FailedPrecondition);
|
|
491
|
+
}
|
|
492
|
+
return this.stock.reserve(request.sku);
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
- **Dans un flux serveur ou bidirectionnel, la traduction ne vaut qu'avant le premier message**
|
|
497
|
+
(validation, entité introuvable, droit refusé), là où un service partagé lève. Après, l'erreur
|
|
498
|
+
devient 13 et sa cause part au journal ; pour choisir le code en cours de flux, lever une
|
|
499
|
+
`ConnectError`. Traduire chaque message coûterait une promesse par message pour un travail qui
|
|
500
|
+
ne sert qu'en cas d'erreur (`GrpcEndpoint.ts#FirstMessageTranslator`).
|
|
501
|
+
- **Détails riches** (`google.rpc.Status`, `ErrorInfo`) : `ConnectError` les porte (`details`).
|
|
502
|
+
Ils voyagent dans le trailer `grpc-status-details-bin`, que les clients bornent avec les
|
|
503
|
+
en-têtes (8 Kio courants) : les garder petits.
|
|
504
|
+
|
|
505
|
+
## Arrêt du serveur
|
|
506
|
+
|
|
507
|
+
À l'arrêt, Nodefony termine les flux avant de vider les connexions, dans cet ordre : sonde de
|
|
508
|
+
disponibilité à 503, fermeture 1001 des WebSocket et **fin des flux longs**, puis drain HTTP.
|
|
509
|
+
|
|
510
|
+
| Ce qui est en vol | Ce qui se passe |
|
|
511
|
+
| ------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
|
512
|
+
| un flux gRPC | interrompu en 14 `UNAVAILABLE` (« réessaie, ailleurs ») ; le client rejoue sur un autre exemplaire |
|
|
513
|
+
| un appel unaire | il finit pendant le drain, comme l'arrêt gracieux de gRPC |
|
|
514
|
+
| un appel neuf après l'annonce HTTP/2 | refusé par HTTP/2, le client réessaie |
|
|
515
|
+
|
|
516
|
+
- **Passer `ctx.signal` à ses sources** : la méthode est prévenue et termine. Une méthode sourde
|
|
517
|
+
qui produit encore est arrêtée quand même — sa lecture et son écriture suivantes échouent —, au
|
|
518
|
+
plus un message en vol après l'interruption. Une méthode bloquée qui n'écoute rien tombe à
|
|
519
|
+
`shutdownTimeout`, connexion détruite.
|
|
520
|
+
- **L'arrêt attend la fin des flux au plus 1 s**, puis le drain commence
|
|
521
|
+
(`http-kernel.ts#HttpKernel.shutdownStreams`).
|
|
522
|
+
- **Un appel unaire plus long que le drain** (5 s par défaut) voit sa connexion détruite, et le
|
|
523
|
+
statut lu dépend alors du client : 14 chez `@grpc/grpc-js` et `grpcurl`, 1 `CANCELLED` chez
|
|
524
|
+
`@connectrpc/connect-node`. Une application aux appels longs règle `shutdownTimeout` au-dessus
|
|
525
|
+
de son échéance.
|
|
526
|
+
|
|
527
|
+
La même liste des flux longs termine aussi SSE et les flux d'octets vivants : une seule
|
|
528
|
+
implémentation pour tous (`StreamLifecycle.ts#ShutdownRegistry`).
|
|
529
|
+
|
|
530
|
+
## 🛡️ CORS — les navigateurs d'une autre origine
|
|
531
|
+
|
|
532
|
+
**Une seule politique CORS, celle de l'application** : la section `grpc` n'en a pas.
|
|
533
|
+
|
|
534
|
+
- **Préflight** : dès qu'un service gRPC est déclaré et que CORS est actif, la politique reçoit au
|
|
535
|
+
démarrage l'union de ses listes avec celles de Connect (`grpc-timeout`,
|
|
536
|
+
`connect-protocol-version`, `x-grpc-web`…, méthodes `POST` et `GET`). Le démarrage l'annonce.
|
|
537
|
+
Sans cela, un client gRPC-Web serait refusé au préflight par la liste par défaut.
|
|
538
|
+
- **Réponse** : sur une réponse gRPC d'une origine autorisée seulement,
|
|
539
|
+
`Access-Control-Expose-Headers` reçoit `Grpc-Status`, `Grpc-Message`,
|
|
540
|
+
`Grpc-Status-Details-Bin`, `Content-Encoding`, `Connect-Content-Encoding`
|
|
541
|
+
(`GrpcEndpoint.ts#exposeRpcHeaders`) : sans eux, un client gRPC-Web ne lit pas le statut.
|
|
542
|
+
- **CSRF** : une origine autorisée par CORS passe ; une autre est refusée en gRPC.
|
|
543
|
+
|
|
544
|
+
Voir [CORS](../../security/docs/cors.md) pour la politique elle-même.
|
|
545
|
+
|
|
546
|
+
## 📡 Observabilité
|
|
547
|
+
|
|
548
|
+
Un appel gRPC se lit dans les journaux comme une requête, avec son code gRPC en plus.
|
|
549
|
+
|
|
550
|
+
- **Ligne d'accès** : le statut HTTP reste celui du fil, et la ligne ajoute
|
|
551
|
+
`RPC : <paquet.Service/Méthode> <NOM_DU_CODE>` (le nom est omis pour `OK`). Un appel en erreur
|
|
552
|
+
fait **une** ligne, comme une exception de route HTTP ; la cause y figure.
|
|
553
|
+
- **Gravité**, convention OTel du serveur gRPC : 0 → INFO ; 2, 4, 12, 13, 14, 15 → ERROR ; les
|
|
554
|
+
autres → WARNING.
|
|
555
|
+
- **Le code se lit une fois, à la fermeture de la réponse** (`http-kernel.ts#HttpKernel.settleRpc`).
|
|
556
|
+
Une réponse non terminée parce que le client est parti se lit 1 `CANCELLED`, le même fait que
|
|
557
|
+
le statut 499.
|
|
558
|
+
- **`HttpContext.rpc`** porte l'appel : système (`grpc`, `grpc-web`, `connect`), méthode, code,
|
|
559
|
+
nom ; `null` hors RPC. Le journal d'audit JSON et le canal de traçage `nodefony.http.request`
|
|
560
|
+
(`IRequestTrace.rpc`) reçoivent le même champ.
|
|
561
|
+
- **`traceparent`** se propage sans rien de plus : les métadonnées gRPC sont des en-têtes HTTP.
|
|
562
|
+
- **Rien par message** : ni trace ni ligne, comme pour les trames WebSocket.
|
|
563
|
+
|
|
564
|
+
Pour une extension OpenTelemetry, la correspondance :
|
|
565
|
+
|
|
566
|
+
| Attribut OTel | Source |
|
|
567
|
+
| -------------------------- | ------------------------------------------ |
|
|
568
|
+
| nom du span | `rpc.method` |
|
|
569
|
+
| `rpc.system.name` | `grpc` (convention stable) ou `connectrpc` |
|
|
570
|
+
| `rpc.method` | `HttpContext.rpc.method` |
|
|
571
|
+
| `rpc.status_code` | le nom du code |
|
|
572
|
+
| `rpc.server.call.duration` | `durationMs / 1000` |
|
|
573
|
+
|
|
574
|
+
## Interopérabilité
|
|
575
|
+
|
|
576
|
+
Éprouvée contre les clients que l'écosystème emploie, en intégration continue :
|
|
577
|
+
|
|
578
|
+
- **`@grpc/grpc-js`** (le client du SDK A2A JavaScript) et **`grpcurl`** (bâti sur grpc-go) :
|
|
579
|
+
unaire en h2c et en TLS, flux bidirectionnel entrelacé, erreur métier, échéance, métadonnées
|
|
580
|
+
texte et binaires (`-bin`), compression gzip, requête de 5 Mio, battements HTTP/2 ;
|
|
581
|
+
- **la suite de conformité officielle de Connect**, ≈ 4 900 cas sur les trois protocoles ;
|
|
582
|
+
- **les trois transports de connect-es** (gRPC, gRPC-Web, Connect).
|
|
583
|
+
|
|
584
|
+
Deux comportements du serveur viennent de ces essais :
|
|
585
|
+
|
|
586
|
+
- **un refus gRPC natif finit sur la trame d'en-têtes** : les clients de la famille grpc-go lisent
|
|
587
|
+
`INTERNAL` quand la fin du flux arrive dans une trame de données ;
|
|
588
|
+
- **un refus HTTP/2 attend un aller-retour avant de couper le flux** d'un client qui émet encore
|
|
589
|
+
(un `PING`, RFC 9113 §6.7) : Node répond avant d'avoir lu les trames déjà en route, et une coupe
|
|
590
|
+
immédiate frappait des clients qui finissaient d'émettre (`http2StreamGrace.ts#closeStreamAfterGrace`).
|
|
591
|
+
|
|
592
|
+
Une requête gRPC native qui aboutit à une route **qui n'est pas gRPC** (une page unique en
|
|
593
|
+
attrape-tout, par exemple) reçoit 12 `UNIMPLEMENTED`, jamais la page.
|
|
594
|
+
|
|
595
|
+
## Installation, chargement et stabilité
|
|
596
|
+
|
|
597
|
+
- **Pairs facultatifs** : `@nodefony/http` déclare les trois paquets, `@nodefony/framework`
|
|
598
|
+
protobuf et Connect, tous en `peerDependencies` facultatives (`^2.3.0` pour Connect, `^2.7.0`
|
|
599
|
+
pour protobuf). L'application qui sert un service les déclare dans ses `dependencies`.
|
|
600
|
+
- **Deux sous-chemins** : `@nodefony/framework/grpc` (ce que tu importes) et `@nodefony/http/grpc`
|
|
601
|
+
(le transport). Le point d'entrée principal de chaque paquet ne cite ni Connect ni protobuf,
|
|
602
|
+
même en type : une application sans gRPC compile sans eux.
|
|
603
|
+
- **Chargement au démarrage, si un service est déclaré** : 171 ms et 6,6 Mo de tas, une fois par
|
|
604
|
+
processus. Un paquet manquant se voit au déploiement, et le message nomme `npm i <paquet>`.
|
|
605
|
+
- **Surface `@experimental`** : `GrpcController`, `@GrpcService` et le transport portent
|
|
606
|
+
l'étiquette TSDoc, visible au survol dans l'éditeur. Ils peuvent changer en version mineure,
|
|
607
|
+
toujours annoncés au changelog ; la liste fait foi dans la
|
|
608
|
+
[politique de compatibilité](../../../../../docs/guides/compatibilite.md).
|
|
609
|
+
|
|
610
|
+
## ⚡ Performance & mémoire
|
|
611
|
+
|
|
612
|
+
Mesuré sur secteur, serveur de production, HTTP/2 + TLS, service d'écho de 16 octets (le pire
|
|
613
|
+
cas : aucun travail métier), contre l'adaptateur Connect nu sur `http2.createSecureServer`,
|
|
614
|
+
médiane de 7 passes en camps alternés (`grpc-unary-overhead.mjs`, skill `nodefony-load-test`) :
|
|
615
|
+
|
|
616
|
+
| Mesure | Valeur |
|
|
617
|
+
| --------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
618
|
+
| surcoût du pipeline par appel unaire | ≈ 84 µs en charge, ≈ 129 µs en série |
|
|
619
|
+
| dont code Nodefony (profil comparé) | ≈ 24 µs : contexte et ALS, pare-feu, routage, ligne d'accès, en-têtes, code au `close` |
|
|
620
|
+
| refus gRPC face au 401 JSON, même route | dans le bruit (×0,93 à ×0,95) |
|
|
621
|
+
| un refus face à un appel servi | moins de la moitié du coût |
|
|
622
|
+
|
|
623
|
+
- **Par message d'un flux** : la relecture de l'interruption, ≈ 34 ns ; aucune allocation de plus.
|
|
624
|
+
- **Par appel unaire** : l'échéance réutilise le minuteur que toute requête arme déjà (≈ 24 ns
|
|
625
|
+
pour accrocher la fiche d'appel) ; aucun écouteur par appel à l'arrêt (le registre des flux
|
|
626
|
+
coûte 44 à 77 ns par flux, constant jusqu'à 10 000 flux ouverts).
|
|
627
|
+
- **Sans service gRPC** : rien d'installé, rien de chargé, un champ `rpc` `null` par contexte.
|
|
628
|
+
|
|
629
|
+
## 📜 Normes appliquées
|
|
630
|
+
|
|
631
|
+
| Norme | Ce qu'elle exige | Où Nodefony s'y conforme |
|
|
632
|
+
| --------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
633
|
+
| gRPC sur HTTP/2 (`PROTOCOL-HTTP2`) | `:path` = `/Service/Méthode`, statut en trailers, Trailers-Only pour une erreur immédiate | une route par méthode (`GrpcService.ts#GrpcService`), refus en Trailers-Only (`refusal.ts#prepareGrpcRefusal`) |
|
|
634
|
+
| gRPC-Web (`PROTOCOL-WEB`) | statut dans un cadre de trailers du corps | écrit par l'adaptateur ; refus en Trailers-Only |
|
|
635
|
+
| Protocole Connect | GET pour `NO_SIDE_EFFECTS` seulement ; code d'erreur textuel ; code déduit du statut HTTP | route GET conditionnelle ; `refusal.ts#connectRefusalCode` |
|
|
636
|
+
| Codes de statut gRPC (`statuscodes.md`) | `PERMISSION_DENIED` interdit sans identité | choix 16 ou 7 sur l'identité (`refusal.ts#transportRefusalCode`) |
|
|
637
|
+
| Correspondance HTTP → gRPC | 404 → 12, 429/503 → 14… | `refusal.ts#transportRefusalCode` |
|
|
638
|
+
| `google/rpc/code.proto` | table HTTP des erreurs d'application | `refusal.ts#applicationErrorCode` |
|
|
639
|
+
| RFC 9113 §6.7 et §8.1 | `PING` prioritaire ; le serveur PEUT couper un flux après une réponse complète | grâce d'un aller-retour (`http2StreamGrace.ts#closeStreamAfterGrace`) |
|
|
640
|
+
| OpenTelemetry, conventions RPC | `rpc.method`, `rpc.status_code`, gravité du serveur | `HttpContext.rpc`, gravité de la ligne d'accès |
|
|
641
|
+
|
|
642
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
643
|
+
|
|
644
|
+
| Symptôme | Cause | Correction |
|
|
645
|
+
| ------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
646
|
+
| Le démarrage s'arrête : « … sans effet sur un appel gRPC » | un décorateur HTTP (`@Body`, `@UseSession`, `@Csp`…) sur une méthode du contrat | le retirer ; un paramètre gRPC est le message, pas la requête HTTP |
|
|
647
|
+
| `Cannot find package '@connectrpc/connect-node'` au démarrage | pair facultatif non installé | `npm i @connectrpc/connect @connectrpc/connect-node @bufbuild/protobuf` |
|
|
648
|
+
| Le client gRPC échoue tout de suite en `INTERNAL` | il vise 5151 (HTTP/1.1) | 5152 en TLS, ou 5153 avec h2c activé |
|
|
649
|
+
| Un client d'agent abandonne au lieu de se reconnecter | il a reçu 7 alors que son jeton a expiré | ne pas réécrire le code : Nodefony rend 16 sans identité |
|
|
650
|
+
| Le flux finit en 13 au lieu du code voulu | erreur levée APRÈS le premier message | lever une `ConnectError` avec le code choisi |
|
|
651
|
+
| La méthode tourne encore après l'arrêt ou la révocation | elle n'écoute pas `ctx.signal` | passer `ctx.signal` aux sources (base, abonnement, `fetch`) |
|
|
652
|
+
| Le client gRPC-Web voit `UNKNOWN` sans statut | réponse d'une origine non autorisée par CORS | autoriser l'origine : l'exposition des en-têtes gRPC suit |
|
|
653
|
+
| Le JSON d'un `google.protobuf.Any` est refusé en 3 | le type emballé est hors du graphe d'imports du contrat | importer son `.proto` dans le contrat ; en binaire il passe |
|
|
654
|
+
| Le client lit 4 alors que le serveur journalise 1 | échéance du client et délai du serveur expirent au même instant | course sans gravité : les deux disent « trop long » |
|
|
655
|
+
| `nodefony/gen/` diffère de ce que génère `buf` | contrat modifié sans régénérer, ou version de `protoc-gen-es` changée | `npx buf generate` et commiter |
|
|
656
|
+
|
|
657
|
+
## 🧪 Tests & couverture
|
|
658
|
+
|
|
659
|
+
Les **chiffres exacts vivent dans la carte de tests** de la page, régénérée depuis les fichiers,
|
|
660
|
+
jamais figée dans le texte.
|
|
661
|
+
|
|
662
|
+
- **unitaires** — `framework/…/unit/grpcService.test.ts` (déclaration, refus au démarrage, routes
|
|
663
|
+
montées, gardes lues sur la méthode du contrat) et `grpcService.types.test.ts` (une méthode
|
|
664
|
+
manquante ou mal typée ne compile pas) ; `http/…/unit/grpcEndpoint.test.ts` (échéance, erreurs
|
|
665
|
+
avant et après le premier message, interruption, code au `close`), `grpcRefusal.test.ts` (les
|
|
666
|
+
deux tables, les formes par protocole), `grpcCors.test.ts` (la copie des listes CORS comparée à
|
|
667
|
+
celle de Connect).
|
|
668
|
+
- **intégration** (serveur réel, trois protocoles, trois ports) — `grpc.test.ts` (appels,
|
|
669
|
+
singleton, GET), `grpc-refusal.test.ts` (16/7, CSRF, aucun refus en HTML ni en JSON, corps non
|
|
670
|
+
lus), `grpc-streams.test.ts` (flux longs, client qui ne lit plus), `grpc-context.test.ts` (ALS au
|
|
671
|
+
cinquième message, en-têtes du pipeline, révocation en 16), `grpc-cors.test.ts`,
|
|
672
|
+
`grpc-observability.test.ts` (ligne d'accès, trace, gravité), `grpc-interop.test.ts`
|
|
673
|
+
(`@grpc/grpc-js` et `grpcurl`).
|
|
674
|
+
- **conformité** — `npm run test:grpc-conformance` : la suite officielle de Connect contre le
|
|
675
|
+
serveur de développement démarré.
|
|
676
|
+
- **charge** — `grpc-unary-overhead.mjs` (surcoût par appel, débit des refus), skill
|
|
677
|
+
`nodefony-load-test`. **Ce qui manque** : un banc de débit d'un flux serveur face à la socket
|
|
678
|
+
binaire, à charge utile égale.
|
|
679
|
+
|
|
680
|
+
Couverture : `npm run coverage` dans `@nodefony/framework` et `@nodefony/http`.
|
|
681
|
+
|
|
682
|
+
## 🔗 Pour aller plus loin
|
|
683
|
+
|
|
684
|
+
- ⬆️ **Retour au hub** : [Framework — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
685
|
+
- 🧭 **Pages sœurs** : [Contrôleurs](controller.md) (cycle de vie, injection) · [Décorateurs](decorateurs.md) (`@IsGranted`, `@Scope`) · [Routage](routing.md) (hôtes virtuels, ordre)
|
|
686
|
+
- Ports, TLS, h2c et proxy inverse → [Serveurs](../../http/docs/servers.md)
|
|
687
|
+
- Qui authentifie avant ta méthode → [Firewall](../../security/docs/firewall.md) · [CORS](../../security/docs/cors.md)
|
|
688
|
+
- Les décisions et leurs essais → [ADR-0026](../../../../../docs/adr/0026-service-grpc-declare-par-un-module.md)
|
|
689
|
+
- Ce qu'« expérimental » engage → [Politique de compatibilité](../../../../../docs/guides/compatibilite.md)
|
|
690
|
+
- Signatures exactes → graphe symbolique `.ai/symbols.json`
|