@nodefony/http 10.0.0-beta.5 → 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/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +30 -26
- package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
- package/dist/nodefony/config/config.js +29 -4
- package/dist/nodefony/grpc/GrpcCall.js +298 -0
- package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
- package/dist/nodefony/grpc/index.js +4 -0
- package/dist/nodefony/interfaces/IRpcCall.js +1 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
- package/dist/nodefony/service/audit-logger.js +52 -6
- package/dist/nodefony/service/error-renderer.js +47 -13
- package/dist/nodefony/service/http-kernel.js +306 -49
- package/dist/nodefony/service/pretty-request-logger.js +5 -3
- package/dist/nodefony/service/request-logger.js +21 -6
- package/dist/nodefony/service/reverse-proxy.js +8 -3
- package/dist/nodefony/service/servers/clientError.js +53 -4
- package/dist/nodefony/service/servers/server-h2c.js +6 -6
- package/dist/nodefony/service/servers/server-http.js +6 -4
- package/dist/nodefony/service/servers/server-https.js +14 -7
- package/dist/nodefony/service/servers/server-static.js +1 -6
- package/dist/nodefony/service/servers/server-websocket-secure.js +7 -11
- package/dist/nodefony/service/servers/server-websocket.js +7 -11
- package/dist/nodefony/service/servers/serverShutdown.js +43 -6
- package/dist/nodefony/service/sessions/sessions-service.js +7 -7
- package/dist/nodefony/service/upload/upload-service.js +2 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
- package/dist/nodefony/src/context/Context.js +12 -1
- package/dist/nodefony/src/context/http/ByteStream.js +364 -0
- package/dist/nodefony/src/context/http/HttpContext.js +139 -32
- package/dist/nodefony/src/context/http/Request.js +111 -22
- package/dist/nodefony/src/context/http/Response.js +459 -37
- package/dist/nodefony/src/context/http/SseStream.js +83 -148
- package/dist/nodefony/src/context/http/StreamLifecycle.js +582 -0
- package/dist/nodefony/src/context/http/parser.js +29 -11
- package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
- package/dist/nodefony/src/context/http/webResponse.js +56 -0
- package/dist/nodefony/src/context/http2/Request.js +1 -1
- package/dist/nodefony/src/context/http2/Response.js +55 -12
- package/dist/nodefony/src/context/requestTrace.js +6 -2
- package/dist/nodefony/src/context/rpcOutcome.js +20 -0
- package/dist/nodefony/src/context/transport.js +20 -1
- package/dist/nodefony/src/context/websocket/Response.js +15 -3
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +86 -18
- package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
- package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
- package/dist/nodefony/src/cookies/cookie.js +57 -14
- package/dist/nodefony/src/errors/httpError.js +58 -25
- package/dist/nodefony/src/grpc/cors.js +59 -0
- package/dist/nodefony/src/grpc/limits.js +16 -0
- package/dist/nodefony/src/grpc/refusal.js +271 -0
- package/dist/nodefony/src/profiler/Profiler.js +14 -1
- package/dist/nodefony/src/profiler/aggregate.js +2 -2
- package/dist/nodefony/src/proxy/forward.js +109 -9
- package/dist/nodefony/src/proxy/generateProxyConfig.js +97 -17
- package/dist/nodefony/src/proxy/rules.js +29 -14
- package/dist/nodefony/src/servers/http2Goaway.js +76 -0
- package/dist/nodefony/src/servers/http2Limits.js +41 -15
- package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
- package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
- package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
- package/dist/types/index.d.ts +11 -2
- package/dist/types/nodefony/config/config.d.ts +14 -0
- package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
- package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
- package/dist/types/nodefony/grpc/index.d.ts +15 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
- package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
- package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
- package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
- package/dist/types/nodefony/interfaces/index.d.ts +2 -1
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
- package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
- package/dist/types/nodefony/service/error-renderer.d.ts +27 -5
- package/dist/types/nodefony/service/http-kernel.d.ts +36 -3
- package/dist/types/nodefony/service/request-logger.d.ts +10 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +7 -5
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
- package/dist/types/nodefony/src/context/Context.d.ts +9 -1
- package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +99 -3
- package/dist/types/nodefony/src/context/http/Request.d.ts +46 -9
- package/dist/types/nodefony/src/context/http/Response.d.ts +188 -8
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +33 -8
- package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +106 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
- package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +40 -5
- package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
- package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
- package/dist/types/nodefony/src/context/transport.d.ts +17 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +48 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
- package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
- package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
- package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
- package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
- package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
- package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
- package/dist/types/nodefony/src/proxy/forward.d.ts +37 -2
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
- package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
- package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
- package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
- package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
- package/docs/byte-stream.md +406 -0
- package/docs/cookies.md +13 -3
- package/docs/diagnostics-channel.md +45 -1
- package/docs/index.md +8 -0
- package/docs/reverse-proxy.md +15 -3
- package/docs/servers.md +67 -43
- package/docs/session.md +3 -3
- package/docs/sse.md +27 -20
- package/docs/upload.md +43 -17
- package/package.json +38 -18
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Flux d'octets — servir la sortie d'un processus, un relais ou une archive à la volée"
|
|
3
|
+
navTitle: Flux d'octets
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/http"
|
|
6
|
+
topic: byte-stream
|
|
7
|
+
section: "Cœur runtime"
|
|
8
|
+
audience: [developer]
|
|
9
|
+
tags:
|
|
10
|
+
[
|
|
11
|
+
streaming,
|
|
12
|
+
flux,
|
|
13
|
+
renderstream,
|
|
14
|
+
openstream,
|
|
15
|
+
ffmpeg,
|
|
16
|
+
relais,
|
|
17
|
+
fetch,
|
|
18
|
+
http2,
|
|
19
|
+
contre-pression,
|
|
20
|
+
]
|
|
21
|
+
version: "doc"
|
|
22
|
+
status: stable
|
|
23
|
+
updated: 2026-11-06
|
|
24
|
+
source: "src/packages/@nodefony/http/docs/byte-stream.md"
|
|
25
|
+
coverageModule: http
|
|
26
|
+
coverageFiles: context/http/ByteStream.ts,context/http/StreamLifecycle.ts
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
📍 [Documentation](../../../../../docs/index.md) › [@nodefony/http](index.md) › **Flux d'octets**
|
|
30
|
+
|
|
31
|
+
# Flux d'octets — servir la sortie d'un processus, un relais ou une archive à la volée
|
|
32
|
+
|
|
33
|
+
> Un **robinet** plutôt qu'une **bouteille** : la réponse ne se prépare pas en entier avant de
|
|
34
|
+
> partir, elle coule pendant que la source produit. La vidéo ré-emballée par ffmpeg, le corps d'un
|
|
35
|
+
> `fetch()` relayé depuis une caméra, une archive construite à la volée : toute source d'octets qui
|
|
36
|
+
> n'est pas un fichier du disque. Un `<video src>`, un `fetch()`, un téléchargement ou `curl` la
|
|
37
|
+
> lisent comme n'importe quelle réponse HTTP. Le même code sert HTTP/1.1, HTTP/2 chiffré et h2c.
|
|
38
|
+
> Chaque fait ci-dessous est ancré sur le code.
|
|
39
|
+
|
|
40
|
+
## 🧠 Le modèle mental — la réponse est PRISE à l'appel, les en-têtes partent au premier octet
|
|
41
|
+
|
|
42
|
+
`this.renderStream(source)` dit au framework : « cette requête, c'est moi qui la sers, avec ce flux ».
|
|
43
|
+
Dès l'appel, plus rien ne peut répondre à sa place. Mais les **en-têtes ne partent qu'au premier
|
|
44
|
+
morceau** de la source : tant que rien n'est sorti, une erreur de la source peut encore devenir une
|
|
45
|
+
vraie page d'erreur (500, 502…), visible dans le journal d'accès — et non une réponse 200 tronquée.
|
|
46
|
+
|
|
47
|
+
Le flux se termine pour l'une de cinq raisons, et la **source est toujours détruite** à la fin,
|
|
48
|
+
quelle que soit la raison. C'est ce qui libère un processus fils bloqué en écriture : un simple
|
|
49
|
+
`unpipe` le laisserait en pause, et ffmpeg ainsi bloqué ignore même `SIGTERM`.
|
|
50
|
+
|
|
51
|
+
## 📖 Lexique
|
|
52
|
+
|
|
53
|
+
- **Source** — ce qui produit les octets : un `Readable` Node (la sortie d'un processus, une archive)
|
|
54
|
+
ou un `ReadableStream` web (le `body` d'un `fetch()`).
|
|
55
|
+
- **Contre-pression** — quand le client lit moins vite que la source produit, la source est mise en
|
|
56
|
+
pause jusqu'à ce que le client rattrape : la mémoire du serveur reste bornée.
|
|
57
|
+
- **Source muette** — une source qui ne produit plus rien (processus figé, amont qui ne répond plus).
|
|
58
|
+
- **Coupure** — la fin d'une réponse sans fin propre : le client SAIT que le corps est tronqué
|
|
59
|
+
(socket fermée en HTTP/1.1, `RST_STREAM` en HTTP/2).
|
|
60
|
+
|
|
61
|
+
## Quand un flux d'octets, quand autre chose ?
|
|
62
|
+
|
|
63
|
+
| Besoin | Choisir |
|
|
64
|
+
| ----------------------------------------------------------- | ----------------------------------------------------------- |
|
|
65
|
+
| Un fichier du disque, avec reprise (`Range`) | `this.renderMediaStream()` / `this.renderFileDownload()` |
|
|
66
|
+
| La sortie d'un processus, un relais, une archive construite | **`this.renderStream()`** — cette page |
|
|
67
|
+
| Des événements texte lus par `EventSource` | [`this.renderSse()`](sse.md) |
|
|
68
|
+
| UNE source diffusée à N lecteurs (un producteur par pod) | un canal temps réel (`@nodefony/realtime`), pas une réponse |
|
|
69
|
+
|
|
70
|
+
`renderStream` sert **une réponse par consommateur** : dix spectateurs, dix flux, dix producteurs.
|
|
71
|
+
|
|
72
|
+
## 🚀 Démarrage rapide
|
|
73
|
+
|
|
74
|
+
Dans une application générée par `nodefony create app`, une action sert la sortie d'un processus.
|
|
75
|
+
|
|
76
|
+
### 1. L'action
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// modules/app/nodefony/controller/ExportController.ts
|
|
80
|
+
import { spawn } from "node:child_process";
|
|
81
|
+
import { Controller, controller, route } from "@nodefony/framework";
|
|
82
|
+
import type { Context } from "@nodefony/http";
|
|
83
|
+
|
|
84
|
+
@controller("/export")
|
|
85
|
+
class ExportController extends Controller {
|
|
86
|
+
constructor(context: Context) {
|
|
87
|
+
super("ExportController", context);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@route("export-logs", { path: "/logs" })
|
|
91
|
+
async logs() {
|
|
92
|
+
const tar = spawn("tar", ["-cz", "-C", "var", "logs"], {
|
|
93
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
const { reason, bytes } = await this.renderStream(tar.stdout, {
|
|
98
|
+
headers: {
|
|
99
|
+
"content-type": "application/gzip",
|
|
100
|
+
"content-disposition": 'attachment; filename="logs.tar.gz"',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
this.log(`export terminé : ${reason}, ${bytes} octets`, "INFO");
|
|
104
|
+
} finally {
|
|
105
|
+
tar.kill("SIGKILL"); // le flux est fini, quelle qu'en soit la raison
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default ExportController;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 2. Observer
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
curl -sS -o logs.tar.gz -D - http://127.0.0.1:5151/export/logs
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
HTTP/1.1 200 OK
|
|
121
|
+
content-type: application/gzip
|
|
122
|
+
cache-control: no-cache, no-transform
|
|
123
|
+
x-accel-buffering: no
|
|
124
|
+
transfer-encoding: chunked
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- Aucun `Content-Length` n'est connu : HTTP/1.1 envoie par morceaux, HTTP/2 n'annonce rien.
|
|
128
|
+
- `Cache-Control: no-cache, no-transform` et `X-Accel-Buffering: no` sont posés **s'ils manquent** —
|
|
129
|
+
ni dans `headers`, ni déjà sur la réponse : un proxy inverse ne met pas le flux en tampon, et ne le
|
|
130
|
+
recompresse pas. Pour une source FINIE et rejouable (fichier, archive), `live: false` les retire.
|
|
131
|
+
- Couper `curl` en route : la promesse rend `reason: "client"`, la source est détruite, `tar` reçoit
|
|
132
|
+
`EPIPE` et s'arrête.
|
|
133
|
+
|
|
134
|
+
## ⚙️ L'API
|
|
135
|
+
|
|
136
|
+
### `this.renderStream(source, options?)` — et `context.openStream()`
|
|
137
|
+
|
|
138
|
+
`Controller.renderStream()` (`Controller.ts#Controller.renderStream`) n'est que l'habillage de
|
|
139
|
+
`HttpContext.openStream()` (`HttpContext.ts#HttpContext.openStream`), comme `renderSse` l'est de
|
|
140
|
+
`openSse`. Un middleware ou un relais qui n'a pas de contrôleur appelle `context.openStream()`
|
|
141
|
+
directement — la requête est alors répondue : ne pas appeler la suite.
|
|
142
|
+
|
|
143
|
+
| Paramètre | Type | Rôle |
|
|
144
|
+
| --------- | ------------------------------------------ | ---------------------------------------------------------------- |
|
|
145
|
+
| `source` | `Readable` ou `ReadableStream<Uint8Array>` | ce qui produit les octets ; jamais en mode objet (`TypeError`) |
|
|
146
|
+
| `options` | `IByteStreamOptions` | statut, en-têtes, délais, écouteur de fin (tableau suivant) |
|
|
147
|
+
| rend | `Promise<IByteStreamResult>` | `{ reason, bytes }` à la fin — la promesse ne rejette **jamais** |
|
|
148
|
+
|
|
149
|
+
**La source appartient au flux dès l'appel.** Un appel qui LÈVE — réponse déjà prise (500), plafond
|
|
150
|
+
par IP atteint (429), option hors bornes (`RangeError`), source en mode objet (`TypeError`) —
|
|
151
|
+
détruit la source avant de lever (un flux web est annulé) : un relais `fetch` refusé ne garde pas sa
|
|
152
|
+
connexion amont, un producteur enfant ne reste pas bloqué en écriture.
|
|
153
|
+
|
|
154
|
+
| Option | Rôle | Défaut |
|
|
155
|
+
| -------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
156
|
+
| `status` | statut de la réponse, 200 à 599 | 200 |
|
|
157
|
+
| `headers` | en-têtes, écrits au premier morceau ; `Content-Length` repris s'il est fourni, et TENU (ci-dessous) | `Content-Type: application/octet-stream` |
|
|
158
|
+
| `live` | flux vivant : `no-cache` et `X-Accel-Buffering: no` par défaut ; `false` pour un fichier, une archive | `true` |
|
|
159
|
+
| `idleTimeout` | délai maximal sans un octet de la source, ms ; `0` le désarme ; coupure entre 1 et 1,25 × ce délai | délai de réponse du serveur (30 s) |
|
|
160
|
+
| `stallTimeout` | délai maximal d'une écriture bloquée (le client ne lit plus), ms ; `false` l'éteint | 30 000 |
|
|
161
|
+
| `maxDuration` | durée de vie du flux, ms ; il est fermé ensuite | `false` — pas de borne |
|
|
162
|
+
| `onClose` | `(reason) => void`, appelé une fois, AVANT la fin de la requête et la résolution ; lié à la requête (ALS) | aucun |
|
|
163
|
+
|
|
164
|
+
**`Content-Length` est tenu.** Annoncé — dans `headers` ou déjà posé sur la réponse —, le corps
|
|
165
|
+
fait exactement cette longueur (RFC 9112 §6.3 ; en HTTP/2, RFC 9113 §8.1.1 : sinon la réponse est
|
|
166
|
+
malformée). Contredit dès le premier morceau : rien n'est parti, page d'erreur 500. Après : coupure,
|
|
167
|
+
jamais une fin propre — un client HTTP/1.1 attendrait sinon l'octet manquant, ou lirait l'excédent
|
|
168
|
+
comme le début de la réponse suivante. `HEAD` excepté : la longueur annoncée y est celle du GET.
|
|
169
|
+
|
|
170
|
+
Les raisons de fin (`ByteStreamEndReason`, `ByteStream.ts#ByteStreamEndReason`) :
|
|
171
|
+
|
|
172
|
+
| `reason` | Ce qui s'est passé | Ce que voit le client |
|
|
173
|
+
| --------- | ------------------------------------------------------------------------------ | ------------------------- |
|
|
174
|
+
| `end` | la source est finie, ou le serveur a fermé le flux (`maxDuration`, révocation) | une fin propre |
|
|
175
|
+
| `client` | le client est parti | — |
|
|
176
|
+
| `error` | la source a échoué | page d'erreur, ou coupure |
|
|
177
|
+
| `timeout` | la source est restée muette plus de `idleTimeout` | 504, ou coupure |
|
|
178
|
+
| `stall` | le client ne lisait plus depuis `stallTimeout` | connexion coupée |
|
|
179
|
+
|
|
180
|
+
L'union **peut s'agrandir** : un `switch` exhaustif sur `reason` garde une branche `default`.
|
|
181
|
+
|
|
182
|
+
### Relayer un autre service
|
|
183
|
+
|
|
184
|
+
**Le plus court : renvoyer la réponse de `fetch` telle quelle.** Une action qui renvoie une
|
|
185
|
+
`Response` Web est envoyée en flux par `HttpContext.openWebResponse()`
|
|
186
|
+
(`HttpContext.ts#HttpContext.openWebResponse`) — même tuyauterie que `openStream`, statut et
|
|
187
|
+
en-têtes de l'amont compris :
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
@route("camera-live", { path: "/camera" })
|
|
191
|
+
camera() {
|
|
192
|
+
return fetch("http://camera.local/stream", { signal: this.context.signal });
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Ce que ce geste court fait pour toi (`webResponse.ts#webResponseParts`) : il recopie le statut et les
|
|
197
|
+
en-têtes de l'amont **sauf** `content-encoding` et `content-length` (le corps a déjà été décompressé
|
|
198
|
+
par `fetch`), `set-cookie` (les cookies d'un tiers ne se posent pas sous ton domaine) et les en-têtes
|
|
199
|
+
de connexion (RFC 9110 §7.6.1). Une `Response` que ton code CONSTRUIT (`new Response(…)`) garde, elle,
|
|
200
|
+
tous ses en-têtes, cookies compris — `response.type` les distingue (`"default"` contre `"basic"`).
|
|
201
|
+
|
|
202
|
+
**Le réglage fin : `renderStream`**, dès qu'il faut refuser un amont en erreur, choisir les en-têtes
|
|
203
|
+
recopiés ou poser des délais :
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
@route("camera-live", { path: "/camera" })
|
|
207
|
+
async camera() {
|
|
208
|
+
const r = await fetch("http://camera.local/stream", { signal: this.context.signal });
|
|
209
|
+
if (!r.ok || r.body === null) throw new HttpError("Caméra injoignable", 502);
|
|
210
|
+
return this.renderStream(r.body, {
|
|
211
|
+
headers: { "content-type": r.headers.get("content-type") ?? "application/octet-stream" },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- Le **signal** est lu AVANT l'appel : au départ du client, le flux l'interrompt, et `fetch` ferme
|
|
217
|
+
la connexion amont (`Context._abortIfPending()`, appelé par la fin du flux).
|
|
218
|
+
- Ne recopier que `content-type` : `fetch` a déjà décompressé le corps, donc `content-encoding` et
|
|
219
|
+
`content-length` de l'amont seraient faux.
|
|
220
|
+
- Une erreur levée **avant** l'appel (le `throw` ci-dessus) appartient à l'action : chemin d'erreur
|
|
221
|
+
ordinaire.
|
|
222
|
+
|
|
223
|
+
### `Readable.from()` — passer `{ objectMode: false }`
|
|
224
|
+
|
|
225
|
+
`Readable.from([...])` crée un flux en **mode objet** par défaut, qui ne porte pas d'octets :
|
|
226
|
+
`renderStream` le refuse par un `TypeError` qui donne le correctif.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
return this.renderStream(Readable.from([header, body], { objectMode: false }));
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Même source, deux sorties — HTTP et ligne de commande
|
|
233
|
+
|
|
234
|
+
`renderStream` exige une **réponse HTTP vivante** : une commande CLI n'en a pas, et n'y a donc pas
|
|
235
|
+
accès. Ce que les deux partagent, c'est la **source**. On écrit le producteur une fois, puis la route
|
|
236
|
+
le sert par `renderStream` et la commande le verse sur sa sortie par `pipeline` de `node:stream`, qui
|
|
237
|
+
apporte la même contre-pression et détruit la source en cas d'erreur (un `| head` qui ferme tôt
|
|
238
|
+
compris).
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// modules/app/nodefony/src/exportLogs.ts — le producteur, écrit une fois
|
|
242
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
243
|
+
import type { Readable } from "node:stream";
|
|
244
|
+
|
|
245
|
+
export function exportLogs(): ChildProcessByStdio<null, Readable, null> {
|
|
246
|
+
return spawn("tar", ["-cz", "-C", "var", "logs"], {
|
|
247
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
248
|
+
windowsHide: true,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
// la route — GET /export/logs
|
|
255
|
+
const tar = exportLogs();
|
|
256
|
+
try {
|
|
257
|
+
await this.renderStream(tar.stdout, {
|
|
258
|
+
headers: { "content-type": "application/gzip" },
|
|
259
|
+
});
|
|
260
|
+
} finally {
|
|
261
|
+
tar.kill("SIGKILL");
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// la commande — nodefony app:export > logs.tar.gz
|
|
267
|
+
import { pipeline } from "node:stream/promises";
|
|
268
|
+
|
|
269
|
+
override async generate(): Promise<this> {
|
|
270
|
+
const tar = exportLogs();
|
|
271
|
+
try {
|
|
272
|
+
await pipeline(tar.stdout, process.stdout);
|
|
273
|
+
} finally {
|
|
274
|
+
tar.kill("SIGKILL");
|
|
275
|
+
}
|
|
276
|
+
return this;
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Une commande de module s'accompagne d'un endpoint équivalent : c'est ce patron qui le rend gratuit.
|
|
281
|
+
Pour LIRE un flux d'octets, aucun client dédié n'est nécessaire — c'est une réponse HTTP
|
|
282
|
+
ordinaire : `curl -N`, `wget`, `fetch()` ou `| tar xz` le consomment tels quels.
|
|
283
|
+
|
|
284
|
+
## 🏗️ Architecture interne — ce que fait l'appel
|
|
285
|
+
|
|
286
|
+
Le flux d'octets repose sur la **brique de cycle de vie commune au SSE**
|
|
287
|
+
(`StreamLifecycle.ts#openStreamLifecycle`, non exportée) — une seule implémentation de la prise de
|
|
288
|
+
la réponse, de la session, de la fermeture et des gardes, jouée par la même table de parité sur les
|
|
289
|
+
deux types de flux.
|
|
290
|
+
|
|
291
|
+
1. **Les options sont contrôlées** et la source web convertie (`ByteStream.ts#fromWebStream`) — sa
|
|
292
|
+
destruction ANNULE le flux web, c'est elle qui ferme l'amont d'un `fetch`.
|
|
293
|
+
2. **La réponse est prise** (`context.sended`), le transport constaté, l'écoute de la fermeture posée
|
|
294
|
+
AVANT toute attente, la place du plafond par IP réservée, puis la session sauvée.
|
|
295
|
+
3. **Le délai d'inactivité de la requête est désarmé** : sous une réponse prise, il ne fermait rien et
|
|
296
|
+
faussait le statut journalisé. Le flux tient son propre chien de garde — UN minuteur périodique
|
|
297
|
+
`unref` par flux, qui passe tous les quarts de `idleTimeout` et compare le compteur d'octets.
|
|
298
|
+
Aucun minuteur ni appel d'horloge par morceau : la coupure tombe entre `idleTimeout` et
|
|
299
|
+
1,25 × `idleTimeout`.
|
|
300
|
+
4. **HEAD** : les en-têtes du GET, aucun corps, la source n'est jamais lue (toute route GET sert HEAD).
|
|
301
|
+
Pour ne pas lancer un producteur coûteux (ffmpeg) pour rien, passer une source VIDE en mode
|
|
302
|
+
octets : `Readable.from([])` seul est en mode objet, donc refusé (500).
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
if (this.method === "HEAD") {
|
|
306
|
+
return this.renderStream(Readable.from([], { objectMode: false }), {
|
|
307
|
+
headers: { "content-type": "video/mp4" },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
5. **Au premier morceau** : les en-têtes, puis le morceau. Sous contre-pression, la source est mise en
|
|
313
|
+
pause et le minuteur `stallTimeout` armé ; il est désarmé au `drain`.
|
|
314
|
+
6. **La fin passe par un seul nettoyage** (`ByteStream.ts#openByteStream`, puis
|
|
315
|
+
`StreamLifecycle.ts#StreamLifecycle.finish`) : source **détruite en premier**, écouteurs retirés,
|
|
316
|
+
minuteurs arrêtés, signal de la requête interrompu (sauf fin propre), `onClose(reason)`, fin de la
|
|
317
|
+
requête, puis résolution de la promesse.
|
|
318
|
+
|
|
319
|
+
**Une erreur de la source**, selon le moment :
|
|
320
|
+
|
|
321
|
+
| Moment | Traitement |
|
|
322
|
+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
323
|
+
| avant l'appel | elle appartient à l'action : chemin d'erreur ordinaire |
|
|
324
|
+
| avant le premier octet (rien n'est parti) | la réponse est ROUVERTE et l'erreur confiée à `HttpKernel.onError` — même statut, même page qu'une action |
|
|
325
|
+
| après le premier octet | **coupure** : `destroy()` en HTTP/1.1, `RST_STREAM(INTERNAL_ERROR)` en HTTP/2 ; une entrée `ERROR` au journal |
|
|
326
|
+
|
|
327
|
+
**Le journal d'accès dit qui a coupé.** Après le premier octet, le statut parti sur le fil est 200 ;
|
|
328
|
+
le statut de la ligne d'accès, lui, est un fait INTERNE : **500** (ou le code d'une `HttpError`) quand
|
|
329
|
+
la source échoue, **504** quand elle se tait, **499** quand le client part. Sans lui, le noyau
|
|
330
|
+
journaliserait 499 (« client parti ») pour une coupure décidée par le serveur
|
|
331
|
+
(`HttpKernel.createHttpContext`).
|
|
332
|
+
|
|
333
|
+
En HTTP/2, la coupure passe par `stream.destroy(err)` et non `stream.close(INTERNAL_ERROR)` : ce
|
|
334
|
+
dernier ferme d'abord le côté écriture, et un client qui a déjà fermé le sien lit une **fin propre**
|
|
335
|
+
(mesuré : `rstCode` 0 au lieu de 2).
|
|
336
|
+
|
|
337
|
+
## 📈 Coût mesuré
|
|
338
|
+
|
|
339
|
+
Banc isolé (serveur Node seul, client `curl`, 256 Mo par passage, médiane de 21 passes alternées,
|
|
340
|
+
deux campagnes) : `renderStream` face au plancher `source.pipe(res)` écrit à la main.
|
|
341
|
+
|
|
342
|
+
| Morceaux | Débit | CPU du serveur |
|
|
343
|
+
| -------- | --------------- | --------------- |
|
|
344
|
+
| 64 Kio | −1,7 % / −2,0 % | +1,6 % / +1,1 % |
|
|
345
|
+
| 4 Kio | −2,4 % / −0,2 % | +2,5 % / +0,1 % |
|
|
346
|
+
|
|
347
|
+
Un fichier servi par `streamFile()` (`live: false`, longueur tenue) face à
|
|
348
|
+
`createReadStream().pipe(res)`, même banc : débit +1,4 %, CPU +2,3 % — dans le bruit, lui aussi.
|
|
349
|
+
|
|
350
|
+
Les écarts restent dans le bruit du banc. Deux choix les tiennent là : un écouteur `drain`
|
|
351
|
+
**permanent** (un `once` par épisode de contre-pression coûtait un enrobage par morceau, presque
|
|
352
|
+
chaque écriture de 64 Kio rendant `false` sur une socket rapide), et un minuteur de blocage
|
|
353
|
+
(`stallTimeout`) créé une fois puis réarmé au reste du délai, jamais un par épisode. La trace de
|
|
354
|
+
désoptimisation de V8 (`--trace-deopt`) ne cite pas `ByteStream`.
|
|
355
|
+
|
|
356
|
+
## 🔐 Sécurité
|
|
357
|
+
|
|
358
|
+
- **Les mêmes gardes qu'une socket WebSocket et qu'un flux SSE** : le flux compte dans
|
|
359
|
+
`wsMaxConnectionsPerIp` (un seul budget, **429** avant d'écrire quoi que ce soit — désactivé par
|
|
360
|
+
défaut), se coupe quand l'identité qui l'a ouvert est révoquée, et respecte `maxDuration`. Le
|
|
361
|
+
plafond est PAR POD, et dépend de `trustProxy` pour lire la vraie IP derrière un proxy.
|
|
362
|
+
- **En-tête refusé** (fin de ligne dans une valeur) : il ne part qu'au premier morceau, depuis la
|
|
363
|
+
source — l'échec rend une page 500 ordinaire, sans en-tête injecté, et ne peut pas abattre le
|
|
364
|
+
processus.
|
|
365
|
+
- **`Range` est ignoré** : la réponse est un 200, et `Accept-Ranges` n'est jamais posé (RFC 9110
|
|
366
|
+
§14.2) — un flux construit à la volée ne sait pas reprendre au milieu.
|
|
367
|
+
- Le flux passe par le pipeline complet : pare-feu, CSRF et `@IsGranted` s'appliquent avant l'action.
|
|
368
|
+
|
|
369
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
370
|
+
|
|
371
|
+
| Symptôme | Cause | Correction |
|
|
372
|
+
| -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
373
|
+
| `TypeError : une source en mode objet` | `Readable.from([...])` est en mode objet par défaut | `Readable.from(chunks, { objectMode: false })` |
|
|
374
|
+
| Un processus fils survit à un crash du processus Node | la destruction de la source n'a jamais eu lieu : le processus Node est mort avant | supervision de l'application (cgroup, `detached: false`, `kill` au signal) |
|
|
375
|
+
| Coupé au bout de 30 s alors que la source est lente | `idleTimeout` (défaut 30 s) : la source n'a rien produit pendant ce délai | relever `idleTimeout`, ou `0` pour une source légitimement silencieuse |
|
|
376
|
+
| Coupé derrière nginx au bout de 60 s | `proxy_read_timeout` (60 s) plus court que le silence de la source | garder `idleTimeout` SOUS ce délai, ou l'allonger sur le proxy |
|
|
377
|
+
| Le flux répond 429 sans avoir rien fait | `wsMaxConnectionsPerIp` atteint — sockets, flux SSE et flux d'octets comptent ensemble | relever le plafond, ou fermer les connexions laissées ouvertes |
|
|
378
|
+
| `content-encoding` faux sur un relais | en-têtes de l'amont recopiés, alors que `fetch` a décompressé | ne recopier que `content-type` — ou `return fetch(…)`, qui les écarte |
|
|
379
|
+
| Requête pendue jusqu'au 408 sur `return new Response(…)` | framework antérieur à la reconnaissance de la `Response` Web | mettre à jour, ou `return this.renderStream(response.body, …)` |
|
|
380
|
+
| Un cookie de l'amont n'arrive pas au navigateur | `return fetch(…)` écarte le `set-cookie` d'une réponse RELAYÉE | le reposer soi-même, en connaissance de cause (`Response.ts#HttpResponse.setCookie`) |
|
|
381
|
+
|
|
382
|
+
## 🧪 Tests & couverture
|
|
383
|
+
|
|
384
|
+
| Type | Où |
|
|
385
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
386
|
+
| Parité du cycle de vie | `http/streamLifecycle.test.ts` — la MÊME table jouée sur le SSE et le flux d'octets : réponse prise, départ du client en HTTP/1.1, HTTP/2 et h2c, fin propre, session, double ouverture, client lent, durée de vie |
|
|
387
|
+
| Parité — unitaire | `unit/streamLifecycleParity.test.ts` — client parti PENDANT la sauvegarde de session : flux né fermé, source détruite |
|
|
388
|
+
| Intégration | `http/byteStream.test.ts` — HEAD (3 transports), coupure après le premier octet (3 transports), 500 / 502 avant, source muette 504 et coupure, `idleTimeout: 0`, source déjà finie, en-tête refusé, producteur bloqué libéré, relais `fetch` ; anneau du journal relu (une seule `ERROR`, aucun « onError on closed context », statut de la ligne d'accès) ; session sauvée deux fois, écrite une fois |
|
|
389
|
+
| `Response` Web et flux nu renvoyés | `http/webResponse.test.ts` — `return new Response(…)` (statut, en-têtes, deux cookies, en-tête de connexion écarté), `Response.json`, réponse sans corps (204), `Response.error()` (502), `return fetch(…)` d'un amont gzip (encodage et cookies de l'amont écartés), `Readable` et `ReadableStream` nus, départ du client (source détruite / annulée) — chaque cas vu ROUGE (408) sans la reconnaissance |
|
|
390
|
+
| Délais réels | même fichier, derrière `NF_RUN_LONG_STREAM=1` (~80 s) — défaut d'`idleTimeout` (coupure entre 30 et 37,5 s, 504 au journal) et `idleTimeout: 0` tenu 40 s, au-delà du délai de socket du serveur |
|
|
391
|
+
| Gardes | `http/sse-guards.attack.test.ts` — plafond par IP (429, place rendue, source refusée détruite), anonyme refusé (401) et révocation, joués sur le SSE ET le flux d'octets |
|
|
392
|
+
| Refus — unitaire | `unit/byteStreamRefusal.test.ts` — un appel qui lève (500, 429, `RangeError`, `TypeError`) détruit la source, annule un flux web |
|
|
393
|
+
| Mémoire | `http/memory.test.ts` — flux finis par leur source et flux abandonnés par le client : pente du tas, scopes et contextes résiduels |
|
|
394
|
+
|
|
395
|
+
Le cas du **producteur bloqué** lance un enfant `node -e` qui écrit sans fin par `fs.writeSync(1, …)`
|
|
396
|
+
— une écriture bloquante, comme ffmpeg — et vérifie qu'il sort en moins de 5 s après le départ du
|
|
397
|
+
client, **sans aucun `kill`** : seule la destruction de sa sortie le libère.
|
|
398
|
+
|
|
399
|
+
L'avertissement « réponse non envoyée » ne peut pas tomber après un `renderStream` : l'action rend
|
|
400
|
+
la promesse du résultat, un objet. Le banc qui relit le journal le garde comme filet, sans pouvoir le
|
|
401
|
+
faire rougir sur ce chemin.
|
|
402
|
+
|
|
403
|
+
## 🔗 Pour aller plus loin
|
|
404
|
+
|
|
405
|
+
- ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
406
|
+
- 🧭 **Pages sœurs** : [Flux SSE](sse.md) — événements texte, même cycle de vie ; [Serveurs](servers.md) — HTTP/2, TLS, délais ; [Proxy inverse](reverse-proxy.md).
|
package/docs/cookies.md
CHANGED
|
@@ -242,7 +242,13 @@ constructeur de `Cookie` (`cookie.ts#Cookie.options`).
|
|
|
242
242
|
### Sérialiser : `serialize()` et `serializeWebSocket()`
|
|
243
243
|
|
|
244
244
|
- `serialize()` (`cookie.ts#Cookie.serialize`) produit la **ligne `Set-Cookie`** complète (attributs dans l'ordre,
|
|
245
|
-
préfixes forcés
|
|
245
|
+
préfixes forcés). `Max-Age` est en **secondes entières**, arrondi vers le bas, et absent sous une
|
|
246
|
+
seconde (`cookie.ts#Cookie.getMaxAge`) : la grammaire de la RFC 6265 (§4.1.1) n'admet ni décimale
|
|
247
|
+
ni zéro, et un navigateur ignore un `Max-Age=3599.874` — le cookie deviendrait un cookie de session.
|
|
248
|
+
- Le **nom**, le `Path` et le `Domain` sont vérifiés à la **pose** (`addCookie()`), par la grammaire
|
|
249
|
+
de la bibliothèque `cookie` (`cookie.ts#Cookie.assertGrammar`) : un nom porteur de `;` ou un
|
|
250
|
+
`Path` comme `/; Domain=evil.example`, qui injecterait un attribut, lève une `TypeError` à l'appel
|
|
251
|
+
plutôt qu'un en-tête faux au `writeHead`.
|
|
246
252
|
- `serializeWebSocket()` (`cookie.ts#Cookie.serializeWebSocket`) produit un **objet** `IWsCookie` (mêmes invariants de sécurité)
|
|
247
253
|
— utilisé quand un cookie doit être décrit hors en-tête HTTP.
|
|
248
254
|
- `toString()` (`cookie.ts#Cookie.toString`) ne rend que `nom=valeurEncodée` (sans attributs).
|
|
@@ -270,11 +276,15 @@ boucle de `setHeader` (qui écraserait tout sauf le dernier). Pour expirer un co
|
|
|
270
276
|
|
|
271
277
|
### Parsing des cookies entrants
|
|
272
278
|
|
|
273
|
-
`cookiesParser(context)` (`cookie.ts#cookiesParser`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie
|
|
274
|
-
|
|
279
|
+
`cookiesParser(context)` (`cookie.ts#cookiesParser`) lit l'en-tête `Cookie:` (via la bibliothèque `cookie`),
|
|
280
|
+
crée un `Cookie` par entrée et l'ajoute au contexte avec `addRequestCookie()`
|
|
275
281
|
(`Context.ts#Context.addRequestCookie`). Il est déclenché automatiquement par le pipeline : `parseCookies()` est appelé à
|
|
276
282
|
l'initialisation du contexte HTTP (`HttpContext.ts#HttpContext.constructor`) **et** WebSocket (`WebsocketContext.ts#WebsocketContext.constructor`).
|
|
277
283
|
|
|
284
|
+
C'est le **seul lecteur** de l'en-tête `Cookie` : le pare-feu lui-même lit le jeton CSRF sur le contexte.
|
|
285
|
+
Chaque valeur est décodée **une fois**, et une séquence `%` invalide est gardée telle quelle au lieu de
|
|
286
|
+
lever (`a=50%25` donne `50%`). Un nom présent deux fois : la **première** occurrence gagne.
|
|
287
|
+
|
|
278
288
|
### Côté WebSocket — lecture oui, écriture non
|
|
279
289
|
|
|
280
290
|
Les cookies **entrants** sont lus au handshake, exactement comme en HTTP (même parseur). Mais la **poignée
|
|
@@ -167,6 +167,7 @@ seule pour l'abonné.
|
|
|
167
167
|
| `statusCode` | fin | statut rendu : celui de l'amont pour une requête relayée, `101` pour un handshake accepté, `null` si le client est parti |
|
|
168
168
|
| `durationMs` | fin | construction du contexte → dernier octet remis (fin du handshake en WebSocket) ; `null` si `timing.enabled: false` |
|
|
169
169
|
| `error` | `error` | l'exception attrapée par le pipeline, `null` sinon |
|
|
170
|
+
| `rpc` | fin | appel RPC (gRPC, gRPC-Web, Connect) servi par la requête — `system`, `method`, `code`, `codeName` — ; `null` hors RPC |
|
|
170
171
|
|
|
171
172
|
Les champs « fin » valent `null` à `start` et `end` : c'est normal, la route et le statut ne sont
|
|
172
173
|
pas encore connus. On les lit sur `asyncEnd`.
|
|
@@ -188,6 +189,43 @@ Pour qui écrit un plugin OTel, la table des attributs du span serveur (conventi
|
|
|
188
189
|
Un **4xx sans exception** ne publie pas `error` : pour un span serveur, OTel demande de laisser le
|
|
189
190
|
statut d'un 4xx non renseigné. C'est à l'abonné de classer un statut ; `statusCode` est là pour ça.
|
|
190
191
|
|
|
192
|
+
### Un appel RPC — gRPC, gRPC-Web, Connect
|
|
193
|
+
|
|
194
|
+
Un appel gRPC répond le plus souvent en HTTP 200 : son résultat voyage dans le trailer
|
|
195
|
+
`grpc-status`. `statusCode` reste le statut HTTP, vrai sur le fil ; le résultat de l'appel est dans
|
|
196
|
+
`rpc`, une COPIE de quatre champs, fixée au `close` de la réponse :
|
|
197
|
+
|
|
198
|
+
| Champ | Contenu |
|
|
199
|
+
| ---------- | ------------------------------------------------------------------------------------------ |
|
|
200
|
+
| `system` | protocole parlé sur le fil : `grpc`, `grpc-web` ou `connect` |
|
|
201
|
+
| `method` | `paquet.Service/Méthode`, tiré de la route |
|
|
202
|
+
| `code` | code de l'appel (0 à 16) ; `null` si l'appel n'a jamais commencé (refusé par le transport) |
|
|
203
|
+
| `codeName` | son nom (`OK`, `UNAVAILABLE`…), `null` avec lui |
|
|
204
|
+
|
|
205
|
+
Le code est celui que lit le client, à une course près : quand l'échéance du client expire, sa
|
|
206
|
+
coupure et le minuteur du serveur se croisent, et le serveur publie 4 ou 1. Une réponse que le
|
|
207
|
+
serveur n'a pas terminée vaut 1 `CANCELLED` — le client est parti, comme le statut 499 —, sauf une
|
|
208
|
+
fin qu'il a lui-même posée (échéance 4, identité révoquée 16). Un refus de l'adaptateur avant le
|
|
209
|
+
gestionnaire (message trop grand, encodage inconnu) porte son code. Une exception devient `error` :
|
|
210
|
+
un appel en erreur publie UN `error`. Rien n'est publié par message d'un flux : un flux est un span,
|
|
211
|
+
de l'ouverture au `close`.
|
|
212
|
+
|
|
213
|
+
Correspondance avec les conventions RPC d'OpenTelemetry :
|
|
214
|
+
|
|
215
|
+
| Attribut OTel | Champ |
|
|
216
|
+
| -------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
217
|
+
| nom du span | `rpc.method` |
|
|
218
|
+
| `rpc.method` | `rpc.method` |
|
|
219
|
+
| `rpc.system.name` | `grpc` (convention stable) ou `connectrpc` (convention en développement) — au choix de l'abonné |
|
|
220
|
+
| `rpc.status_code` | `rpc.codeName` |
|
|
221
|
+
| `error.type` | dérivé de `error`, sinon `rpc.codeName` pour un code d'erreur |
|
|
222
|
+
| `rpc.server.call.duration` | `durationMs / 1000` — même horloge que le span |
|
|
223
|
+
|
|
224
|
+
Un code est une erreur du serveur — statut du span en erreur — pour 2, 4, 12, 13, 14 et 15 ; les
|
|
225
|
+
autres codes non nuls sont des refus ou des échecs du client. La ligne d'accès suit la même règle
|
|
226
|
+
pour sa gravité, et ajoute `RPC : <méthode> <NOM>` au statut HTTP. `traceparent` voyage dans les
|
|
227
|
+
métadonnées gRPC, qui sont des en-têtes HTTP : la propagation W3C existante les lit.
|
|
228
|
+
|
|
191
229
|
## La séquence, sortie par sortie
|
|
192
230
|
|
|
193
231
|
| Situation | Événements publiés | `statusCode` |
|
|
@@ -240,6 +278,9 @@ pas — ou on se désabonne (`channel.unsubscribe(subscribers)`).
|
|
|
240
278
|
- **Conventions sémantiques HTTP d'OpenTelemetry** — `http.route` exige un gabarit de faible
|
|
241
279
|
cardinalité, d'où `routePath` ; la durée de `http.server.request.duration` doit égaler celle du
|
|
242
280
|
span serveur, d'où une seule horloge.
|
|
281
|
+
- **Conventions sémantiques RPC d'OpenTelemetry** (`docs/rpc/rpc-spans.md`, `grpc.md`,
|
|
282
|
+
`connect-rpc.md`, `rpc-metrics.md`) — nom du span = `rpc.method`, jamais une valeur inventée ;
|
|
283
|
+
codes d'erreur du serveur gRPC (« SHOULD be considered errors ») ; durée sur l'horloge du span.
|
|
243
284
|
|
|
244
285
|
## Sécurité
|
|
245
286
|
|
|
@@ -261,11 +302,14 @@ Mesuré au micro-banc (Node 26, médiane de 7 passages) :
|
|
|
261
302
|
| ----------------------------- | ------------------------------------------------------------------------------------- |
|
|
262
303
|
| Personne n'écoute (le défaut) | une lecture de `hasSubscribers` (~1,6 ns), un champ `null` sur le contexte (8 octets) |
|
|
263
304
|
| Un abonné | un objet de message, l'appel des abonnés, l'entrée dans les stores liés |
|
|
305
|
+
| Un appel RPC, avec un abonné | une copie de quatre champs (`rpc`), à la fin — rien par message d'un flux |
|
|
264
306
|
|
|
265
307
|
`diagnostics_channel.hasSubscribers("nom")` coûterait ~41 ns : la garde lit donc le
|
|
266
308
|
`TracingChannel` gardé en mémoire de module, jamais une recherche par nom. Le gate mémoire
|
|
267
309
|
(`npm run test:memory`) est vert **sans** abonné et **avec** un abonné armé : le message ne retient
|
|
268
|
-
ni contexte ni scope.
|
|
310
|
+
ni contexte ni scope. Un abonné peut le garder après la fin de la requête : son `error` ne
|
|
311
|
+
retient pas davantage la requête — une `HttpError` lit le contexte à sa construction et n'en garde
|
|
312
|
+
aucune référence.
|
|
269
313
|
|
|
270
314
|
## Pièges
|
|
271
315
|
|
package/docs/index.md
CHANGED
|
@@ -68,6 +68,8 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
|
|
|
68
68
|
| [Rate-limit](rate-limit.md) | plafonner le débit par client (429) | protéger une API d'un flood ou d'un abus |
|
|
69
69
|
| [Observabilité](observabilite.md) | tracer et journaliser chaque requête | débugger en prod, corréler des logs |
|
|
70
70
|
| [Canal de diagnostic](diagnostics-channel.md) | publier le cycle de chaque requête à un outil de traçage, sans monkey-patch | brancher OpenTelemetry, Datadog ou Sentry |
|
|
71
|
+
| [Flux SSE](sse.md) | pousser des événements texte au navigateur sur une réponse qui ne finit pas | progression d'une tâche, réponse d'un modèle mot à mot, journal en direct |
|
|
72
|
+
| [Flux d'octets](byte-stream.md) | servir la sortie d'un processus, un relais `fetch` ou une archive à la volée | ffmpeg vers un `<video>`, caméra relayée, export `tar` |
|
|
71
73
|
| [Proxy inverse](reverse-proxy.md) | relayer un préfixe d'URL vers un autre serveur, sur la même origine | servir Vite en dev, adosser un service interne sans CORS |
|
|
72
74
|
| [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) | l'ordre exact des étapes, HTTP comme WS | tu débugges « pourquoi ça passe / ça bloque ici » |
|
|
73
75
|
|
|
@@ -94,6 +96,12 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
|
|
|
94
96
|
{ "icon": "📡", "title": "diagnostics-channel", "href": "diagnostics-channel.md",
|
|
95
97
|
"desc": "Le TracingChannel nodefony.http.request : chaque requête HTTP et chaque handshake WebSocket publiés avec route, gabarit, module, requestId, statut réel et durée. Zéro coût sans abonné ; correspondance avec les attributs OpenTelemetry.",
|
|
96
98
|
"meta": "brancher un APM sans patcher le framework" },
|
|
99
|
+
{ "icon": "📻", "title": "sse", "href": "sse.md",
|
|
100
|
+
"desc": "Le flux text/event-stream : this.renderSse(), send/comment/retry/close, négociation JSON ou flux sur la même route, client NodefonySse, et les gardes d'une connexion longue (plafond par IP, révocation, lecteur bloqué).",
|
|
101
|
+
"meta": "le serveur parle, le navigateur écoute" },
|
|
102
|
+
{ "icon": "🚰", "title": "byte-stream", "href": "byte-stream.md",
|
|
103
|
+
"desc": "Répondre par une source d'octets qui n'est pas un fichier : this.renderStream() sur un Readable ou un fetch().body, en-têtes au premier octet (une erreur précoce reste une vraie page 5xx), source détruite à la fin, chien de garde d'une source muette, mêmes gardes que le SSE.",
|
|
104
|
+
"meta": "un robinet, pas une bouteille" },
|
|
97
105
|
{ "icon": "↪️", "title": "reverse-proxy", "href": "reverse-proxy.md",
|
|
98
106
|
"desc": "Relayer un préfixe d'URL vers un autre serveur sans quitter l'origine de la page : HTTP et WebSocket, en-têtes de connexion jamais transmis, X-Forwarded-* et Via posés, barrière d'hôte appliquée avant le relais. C'est par lui que Vite est servi en développement.",
|
|
99
107
|
"meta": "une origine, un certificat — pas de CORS ni de contenu mixte" },
|
package/docs/reverse-proxy.md
CHANGED
|
@@ -140,8 +140,11 @@ Ce qui s'applique malgré tout, parce que posé à l'entrée, avant l'aiguillage
|
|
|
140
140
|
- les **quotas** : le rate-limit par IP compte les requêtes relayées, et un upgrade WebSocket relayé
|
|
141
141
|
obéit aux mêmes bornes qu'un upgrade servi (voir [WebSocket relayé](#-websocket-relayé)).
|
|
142
142
|
|
|
143
|
-
La CSP à nonce de l'application
|
|
144
|
-
serveur, dont les scripts ne portent pas ce nonce.
|
|
143
|
+
La CSP à nonce de l'application n'est jamais imposée à un préfixe relayé : elle casserait les
|
|
144
|
+
pages d'un autre serveur, dont les scripts ne portent pas ce nonce. Dans l'autre sens,
|
|
145
|
+
`proxy:generate` ne pose **aucune** CSP dans la configuration nginx ou haproxy qu'il rend. Écrire
|
|
146
|
+
la politique au frontal plutôt que dans l'application →
|
|
147
|
+
[Poser la CSP au proxy](../../security/docs/headers.md#poser-la-csp-au-proxy-nginx-ingress).
|
|
145
148
|
|
|
146
149
|
## 🚀 Démarrage rapide
|
|
147
150
|
|
|
@@ -397,6 +400,7 @@ Ce que le relais fait aux en-têtes, dans les deux sens :
|
|
|
397
400
|
| `X-Forwarded-Prefix` : à la suite de celui d'un pair `trustProxy` | |
|
|
398
401
|
| `Forwarded` d'un pair `trustProxy` : prolongé de ce saut, jamais créé | |
|
|
399
402
|
| prolonge `Via` ; recalcule le cadrage du corps | |
|
|
403
|
+
| décrémente `Max-Forwards` sur `OPTIONS` et `TRACE` | |
|
|
400
404
|
|
|
401
405
|
Les échecs, dans l'ordre où ils peuvent survenir (`forwardRequest()`, `forward.ts#forwardRequest`) :
|
|
402
406
|
|
|
@@ -412,6 +416,13 @@ Les échecs, dans l'ordre où ils peuvent survenir (`forwardRequest()`, `forward
|
|
|
412
416
|
- **coupure** — si l'amont tombe APRÈS avoir commencé à répondre, la réponse au client est
|
|
413
417
|
interrompue : un statut ne se rattrape pas une fois envoyé.
|
|
414
418
|
|
|
419
|
+
Une requête `OPTIONS` ou `TRACE` dont `Max-Forwards` vaut `0` n'atteint pas l'amont : le
|
|
420
|
+
proxy y répond lui-même, en destinataire final (RFC 9110 §7.6.2). `OPTIONS` reçoit `200` avec
|
|
421
|
+
`Allow` si le montage restreint ses `methods` ; `TRACE` reçoit la requête en `message/http`, sans
|
|
422
|
+
les en-têtes qui portent un secret (`Cookie`, `Authorization`, jetons, clés). Au-dessus de `0`, la
|
|
423
|
+
valeur part décrémentée, plafonnée à `MAX_VIA_HOPS` ; une valeur qui n'est pas un entier part
|
|
424
|
+
retirée. Les autres méthodes la relaient telle quelle.
|
|
425
|
+
|
|
415
426
|
Chaque montage a son **pool keep-alive dédié**, jamais l'agent global de Node : sous
|
|
416
427
|
`NODE_USE_ENV_PROXY` (poste d'entreprise), l'agent global enverrait un amont local au proxy de
|
|
417
428
|
l'entreprise (`ReverseProxy.agentFor()`, `reverse-proxy.ts#ReverseProxy.agentFor`). Les pools sont fermés à l'arrêt du
|
|
@@ -444,8 +455,9 @@ une connexion à usage unique, sans recréer de pool que personne ne fermerait.
|
|
|
444
455
|
| RFC 9110 §7.6.1 | Un intermédiaire retire les en-têtes de connexion | Liste fixe + jetons de `Connection`, dans les deux sens (`requestHeadersFor()`, `forward.ts#requestHeadersFor`) |
|
|
445
456
|
| RFC 9110 §7.6.3 | Un intermédiaire ajoute son entrée à `Via` | Pseudonyme par processus, prolongé à l'aller et au retour (`forward.ts#requestHeadersFor`) |
|
|
446
457
|
| RFC 9112 §6.3 | Un seul cadrage de corps ; `Transfer-Encoding` l'emporte | `Content-Length` retiré quand le corps est découpé (`bodyFramingOf()`, `forward.ts#bodyFramingOf`) |
|
|
447
|
-
| RFC 9112 §6.1 | Codage de transfert non compris → 501 | Tout autre que `chunked` seul est refusé, jamais réétiqueté (`forward.ts#
|
|
458
|
+
| RFC 9112 §6.1 | Codage de transfert non compris → 501 | Tout autre que `chunked` seul est refusé, jamais réétiqueté (`forward.ts#bodyFramingOf`) |
|
|
448
459
|
| RFC 9113 §8.2.2 | Pas d'en-tête de connexion en HTTP/2 | Réponse refusée par la sortie h2 → 502, jamais une exception non rattrapée |
|
|
460
|
+
| RFC 9110 §7.6.2 | `Max-Forwards` décrémenté ; à 0, répondre soi-même | Sur `OPTIONS`/`TRACE` seulement (`receivedMaxForwards()`, `forward.ts#receivedMaxForwards`) |
|
|
449
461
|
| RFC 7239 §8.1 | Ne croire `Forwarded` que d'un relais de confiance | `trustProxy` décide (`ReverseProxy.forward()`, `reverse-proxy.ts#ReverseProxy.forward`) |
|
|
450
462
|
| RFC 7239 §4 · §6 | Chaque relais ajoute son élément ; IPv6 entre crochets | Élément `for=…;proto=…;host=…` ajouté, valeurs non-jeton entre guillemets |
|
|
451
463
|
| RFC 6585 §4 | `429 Too Many Requests` | Quota par IP dépassé sur un upgrade relayé, avant le `101` |
|