@nodefony/http 10.0.0-beta.3 → 10.0.0-beta.5
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.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
- package/dist/index.js +13 -10
- package/dist/nodefony/command/assetsPublishCommand.js +5 -3
- package/dist/nodefony/command/certificatesCommand.js +5 -3
- package/dist/nodefony/command/networkCommand.js +5 -3
- package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
- package/dist/nodefony/config/config.js +3 -3
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +2 -2
- package/dist/nodefony/service/ProfilerAdminApi.js +13 -3
- package/dist/nodefony/service/audit-logger.js +23 -30
- package/dist/nodefony/service/certificates.js +97 -13
- package/dist/nodefony/service/error-renderer.js +29 -12
- package/dist/nodefony/service/http-kernel.js +198 -50
- package/dist/nodefony/service/pretty-request-logger.js +15 -13
- package/dist/nodefony/service/request-logger.js +24 -16
- package/dist/nodefony/service/reverse-proxy.js +3 -3
- package/dist/nodefony/service/servers/clientError.js +22 -3
- package/dist/nodefony/service/servers/server-h2c.js +126 -0
- package/dist/nodefony/service/servers/server-http.js +5 -8
- package/dist/nodefony/service/servers/server-https.js +12 -24
- package/dist/nodefony/service/servers/server-static.js +3 -3
- package/dist/nodefony/service/servers/server-websocket-secure.js +10 -18
- package/dist/nodefony/service/servers/server-websocket.js +10 -18
- package/dist/nodefony/service/servers/serverShutdown.js +78 -18
- package/dist/nodefony/service/servers/wsHeartbeat.js +1 -2
- package/dist/nodefony/service/sessions/sessions-service.js +7 -14
- package/dist/nodefony/service/trace.js +1 -2
- package/dist/nodefony/service/upload/upload-service.js +5 -8
- package/dist/nodefony/service/x509.js +1 -2
- package/dist/nodefony/src/assets/collectAssets.js +1 -2
- package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
- package/dist/nodefony/src/context/Context.js +78 -43
- package/dist/nodefony/src/context/domainMatcher.js +1 -2
- package/dist/nodefony/src/context/forwarded.js +1 -2
- package/dist/nodefony/src/context/http/HttpContext.js +28 -6
- package/dist/nodefony/src/context/http/Request.js +33 -29
- package/dist/nodefony/src/context/http/Response.js +23 -6
- package/dist/nodefony/src/context/http/SseStream.js +1 -2
- package/dist/nodefony/src/context/http/parser.js +36 -10
- package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
- package/dist/nodefony/src/context/http2/Request.js +4 -8
- package/dist/nodefony/src/context/http2/Response.js +41 -9
- package/dist/nodefony/src/context/metaData.js +1 -2
- package/dist/nodefony/src/context/requestId.js +1 -2
- package/dist/nodefony/src/context/requestTrace.js +104 -0
- package/dist/nodefony/src/context/responseEnded.js +1 -2
- package/dist/nodefony/src/context/timingMode.js +21 -0
- package/dist/nodefony/src/context/transport.js +45 -0
- package/dist/nodefony/src/context/trustProxy.js +1 -2
- package/dist/nodefony/src/context/unsentResponse.js +30 -0
- package/dist/nodefony/src/context/websocket/Response.js +33 -6
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +104 -25
- package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
- package/dist/nodefony/src/context/websocket/websocketUrl.js +1 -2
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
- package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
- package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
- package/dist/nodefony/src/cookies/cookie.js +3 -7
- package/dist/nodefony/src/errors/httpError.js +1 -2
- package/dist/nodefony/src/errors/thrownMessage.js +1 -2
- package/dist/nodefony/src/profiler/FrameProfile.js +23 -2
- package/dist/nodefony/src/profiler/Profiler.js +104 -32
- package/dist/nodefony/src/profiler/aggregate.js +442 -0
- package/dist/nodefony/src/proxy/forward.js +29 -5
- package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
- package/dist/nodefony/src/proxy/rules.js +12 -6
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
- package/dist/nodefony/src/servers/http2Limits.js +50 -0
- package/dist/nodefony/src/servers/kernelServers.js +12 -3
- package/dist/nodefony/src/servers/portBinder.js +41 -10
- package/dist/nodefony/src/servers/upgradeDispatch.js +86 -7
- package/dist/nodefony/src/session/session.js +2 -3
- package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
- package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
- package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
- package/dist/types/index.d.ts +9 -3
- package/dist/types/nodefony/config/config.d.ts +4 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +72 -4
- package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
- package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
- package/dist/types/nodefony/interfaces/index.d.ts +3 -2
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
- package/dist/types/nodefony/service/certificates.d.ts +55 -4
- package/dist/types/nodefony/service/error-renderer.d.ts +31 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +96 -10
- package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
- package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
- package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -3
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -3
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
- package/dist/types/nodefony/src/context/Context.d.ts +38 -5
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +16 -2
- package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
- package/dist/types/nodefony/src/context/http/Response.d.ts +11 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
- package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +27 -5
- package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
- package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
- package/dist/types/nodefony/src/context/transport.d.ts +53 -0
- package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +58 -8
- package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +28 -12
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +79 -111
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +132 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +14 -2
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +3 -0
- package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
- package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
- package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +33 -3
- package/docs/cookies.md +85 -78
- package/docs/diagnostics-channel.md +303 -0
- package/docs/index.md +6 -1
- package/docs/observabilite.md +159 -81
- package/docs/rate-limit.md +83 -83
- package/docs/reverse-proxy.md +72 -70
- package/docs/servers.md +338 -224
- package/docs/session.md +179 -179
- package/docs/sse.md +24 -20
- package/docs/upload.md +120 -117
- package/package.json +8 -9
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/sse.md
CHANGED
|
@@ -70,12 +70,12 @@ ce que fait une porte MCP.
|
|
|
70
70
|
>
|
|
71
71
|
> Ce plafond vaut pour l'origine que **voit le navigateur**. Derrière nginx, HAProxy ou un
|
|
72
72
|
> ingress qui termine le TLS, cette origine est le proxy : c'est **lui** qui doit servir HTTP/2,
|
|
73
|
-
> et `proxy:generate` le pose déjà (`http2 on`, `generateProxyConfig.ts
|
|
74
|
-
> alors du HTTP/1.1 (`proxy_http_version 1.1`, `generateProxyConfig.ts
|
|
73
|
+
> et `proxy:generate` le pose déjà (`http2 on`, `generateProxyConfig.ts#nginxServerBlock`). Nodefony reçoit
|
|
74
|
+
> alors du HTTP/1.1 (`proxy_http_version 1.1`, `generateProxyConfig.ts#nginxServerBlock`) — y activer HTTP/2
|
|
75
75
|
> n'y change rien. Sans proxy, c'est Nodefony qui sert HTTP/2.
|
|
76
76
|
>
|
|
77
77
|
> De chaque côté du proxy, un flux est une connexion qui se tait entre deux événements. Le
|
|
78
|
-
> battement (`SSE_HEARTBEAT_MS`, 15 s, `SseStream.ts
|
|
78
|
+
> battement (`SSE_HEARTBEAT_MS`, 15 s, `SseStream.ts#SSE_HEARTBEAT_MS`) la garde vivante ; le délai
|
|
79
79
|
> d'inactivité généré (300 s au moins) en tient vingt. Le seul piège réel : une action qui éteint
|
|
80
80
|
> le battement (`heartbeat: false`) et se tait plus longtemps que ce délai se fait couper par le
|
|
81
81
|
> proxy. Le guide [reverse-proxy](../../../../../docs/guides/reverse-proxy.md) détaille le reste.
|
|
@@ -142,23 +142,27 @@ data: {"step":"écriture"}
|
|
|
142
142
|
|
|
143
143
|
- Les en-têtes partent dès `renderSse()` : `Content-Type: text/event-stream; charset=utf-8`,
|
|
144
144
|
`Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no` (`openSseStream()`,
|
|
145
|
-
`SseStream.ts
|
|
145
|
+
`SseStream.ts#openSseStream`). Ce dernier demande à un proxy de type nginx de ne pas garder le
|
|
146
|
+
flux en tampon. À travers le frontal que génère `nodefony proxy:generate nginx`, chaque
|
|
147
|
+
événement arrive à son émission — mesuré par le scénario `edge` du banc de publication. La même
|
|
148
|
+
mesure, en-tête retiré, reste étalée sur nginx 1.30 : l'en-tête n'y est pas ce qui tient le
|
|
149
|
+
flux, il protège les proxys qui retiennent.
|
|
146
150
|
- `await sse.send()` freine l'action si le client lit lentement — c'est la contre-pression.
|
|
147
151
|
|
|
148
152
|
## ⚙️ Les deux bouts de l'API
|
|
149
153
|
|
|
150
154
|
### Côté serveur — `this.renderSse()` et l'objet `SseStream`
|
|
151
155
|
|
|
152
|
-
| Membre | Rôle
|
|
153
|
-
| ------------------------------- |
|
|
154
|
-
| `this.renderSse(options?)` | ouvre le flux (`Controller.renderSse()`, `Controller.ts
|
|
155
|
-
| `this.acceptsSse()` | le client a-t-il demandé `text/event-stream` ? (`Controller.acceptsSse()`, `Controller.ts
|
|
156
|
-
| `sse.send(data, { event, id })` | un événement ; texte tel quel, objet en JSON (`SseStream.send()`, `SseStream.ts
|
|
157
|
-
| `sse.comment(text)` | un commentaire, ignoré par le client
|
|
158
|
-
| `sse.retry(ms)` | impose le délai de reconnexion du client
|
|
159
|
-
| `sse.onClose(fn)` | appelé à la fermeture, quel que soit le bout qui ferme (`SseStream.onClose()`, `SseStream.ts
|
|
160
|
-
| `sse.close()` | termine la requête, idempotent (`SseStream.close()`, `SseStream.ts
|
|
161
|
-
| `sse.closed` | le flux est-il fermé — à tester dans une boucle longue
|
|
156
|
+
| Membre | Rôle |
|
|
157
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `this.renderSse(options?)` | ouvre le flux (`Controller.renderSse()`, `Controller.ts#Controller.renderSse`) ; lève sur WebSocket |
|
|
159
|
+
| `this.acceptsSse()` | le client a-t-il demandé `text/event-stream` ? (`Controller.acceptsSse()`, `Controller.ts#Controller.acceptsSse`) |
|
|
160
|
+
| `sse.send(data, { event, id })` | un événement ; texte tel quel, objet en JSON (`SseStream.send()`, `SseStream.ts#SseStream.send`) |
|
|
161
|
+
| `sse.comment(text)` | un commentaire, ignoré par le client |
|
|
162
|
+
| `sse.retry(ms)` | impose le délai de reconnexion du client |
|
|
163
|
+
| `sse.onClose(fn)` | appelé à la fermeture, quel que soit le bout qui ferme (`SseStream.onClose()`, `SseStream.ts#SseStream.onClose`) |
|
|
164
|
+
| `sse.close()` | termine la requête, idempotent (`SseStream.close()`, `SseStream.ts#SseStream.close`) |
|
|
165
|
+
| `sse.closed` | le flux est-il fermé — à tester dans une boucle longue |
|
|
162
166
|
|
|
163
167
|
Options de `renderSse()` :
|
|
164
168
|
|
|
@@ -185,13 +189,13 @@ async runTask() {
|
|
|
185
189
|
}
|
|
186
190
|
```
|
|
187
191
|
|
|
188
|
-
`acceptsEventStream()` (`SseStream.ts
|
|
192
|
+
`acceptsEventStream()` (`SseStream.ts#acceptsEventStream`) ne retient `text/event-stream` que **nommé** avec un
|
|
189
193
|
poids non nul : `*/*` ne suffit pas — un client qui ne nomme pas le flux ne sait pas le lire —, et
|
|
190
194
|
`q=0` est un refus (RFC 9110 §12.4.2).
|
|
191
195
|
|
|
192
196
|
### Côté client — `NodefonySse`
|
|
193
197
|
|
|
194
|
-
`NodefonySse` (`NodefonySse.ts
|
|
198
|
+
`NodefonySse` (`NodefonySse.ts#NodefonySse`) reprend le vocabulaire d'`EventSource` — `onopen`,
|
|
195
199
|
`onmessage`, `onerror`, `addEventListener(type)`, `readyState`, `lastEventId`, `close()`,
|
|
196
200
|
reconnexion avec `Last-Event-ID` — et ajoute ce que le natif refuse : `method`, `headers` (un
|
|
197
201
|
`Authorization: Bearer`), `body`. Il est bâti sur `fetch`, donc tourne aussi sous Node, où
|
|
@@ -205,7 +209,7 @@ reconnexion avec `Last-Event-ID` — et ajoute ce que le natif refuse : `method`
|
|
|
205
209
|
| autre statut (204 compris), autre type | `error`, `CLOSED`, **sans** reconnexion (WHATWG §9.2.3) |
|
|
206
210
|
| événement au-delà de `maxEventSize` (4 Mi) | `CLOSED` sans reconnexion |
|
|
207
211
|
|
|
208
|
-
L'analyseur du format est `SseParser` (`SseParser.ts
|
|
212
|
+
L'analyseur du format est `SseParser` (`SseParser.ts#SseParser`), exporté par `nodefony/client` : c'est
|
|
209
213
|
le même qui relit le flux dans les tests du serveur.
|
|
210
214
|
|
|
211
215
|
## 🏗️ Architecture interne — ce que fait l'ouverture
|
|
@@ -213,20 +217,20 @@ le même qui relit le flux dans les tests du serveur.
|
|
|
213
217
|
1. **La réponse est prise** (`context.sended`) : plus rien ne peut répondre à la place du flux.
|
|
214
218
|
2. **La session est sauvée** s'il y en a une — ses cookies partent avec les en-têtes.
|
|
215
219
|
3. **Le transport se constate** : flux HTTP/2, ou réponse HTTP/1.1 ; ni l'un ni l'autre → 500,
|
|
216
|
-
avant d'avoir rien écrit (`startSseStream()`, `SseStream.ts
|
|
220
|
+
avant d'avoir rien écrit (`startSseStream()`, `SseStream.ts#startSseStream`).
|
|
217
221
|
4. **Les en-têtes partent** — `respond()` en HTTP/2, `flushHeaders()` en HTTP/1.1 — et le délai
|
|
218
222
|
d'inactivité de la requête est **désarmé** : sans cela, le flux vivant prendrait un 408 au bout
|
|
219
223
|
de 30 s.
|
|
220
224
|
5. **Le battement démarre** sur un minuteur `unref()`.
|
|
221
225
|
|
|
222
|
-
La fin, quel que soit le bout qui ferme, passe par **un seul** nettoyage (`SseStream.ts
|
|
226
|
+
La fin, quel que soit le bout qui ferme, passe par **un seul** nettoyage (`SseStream.ts#SseStream.send`) :
|
|
223
227
|
minuteur arrêté, écouteurs `close` et `drain` retirés, attente de `drain` libérée, puis les
|
|
224
228
|
écouteurs `onClose`.
|
|
225
229
|
|
|
226
230
|
## 🔐 Sécurité
|
|
227
231
|
|
|
228
232
|
- **Injection de flux** : `event` et `id` sont refusés s'ils contiennent CR, LF ou NUL
|
|
229
|
-
(`singleLine()`, `SseStream.ts
|
|
233
|
+
(`singleLine()`, `SseStream.ts#singleLine`) — une valeur venue d'un utilisateur ne peut pas clore
|
|
230
234
|
l'événement pour en forger un autre. Les `data` sur plusieurs lignes sont découpés en autant de
|
|
231
235
|
champs, jamais recopiés tels quels.
|
|
232
236
|
- **Client** : un serveur qui n'envoie jamais de fin de ligne ferait grossir la mémoire du client
|
package/docs/upload.md
CHANGED
|
@@ -42,7 +42,7 @@ coverageFiles: context/http/Request.ts,context/http/parser.ts,service/upload/upl
|
|
|
42
42
|
|
|
43
43
|
Le corps d'une requête n'est **jamais** interprété par le serveur ni par le routeur : c'est
|
|
44
44
|
`HttpRequest` qui, selon le `Content-Type`, choisit **un** parser et remplit deux emplacements que ton
|
|
45
|
-
contrôleur consomme — `
|
|
45
|
+
contrôleur consomme — `body` (les champs) et `files` (les fichiers).
|
|
46
46
|
|
|
47
47
|
```mermaid
|
|
48
48
|
flowchart TD
|
|
@@ -51,21 +51,25 @@ flowchart TD
|
|
|
51
51
|
RT -->|"x-www-form-urlencoded"| Q["ParserQs"]
|
|
52
52
|
RT -->|"application/xml · text/xml"| X["ParserXml"]
|
|
53
53
|
RT -->|"multipart/form-data"| M["busboy → disque (temp)"]
|
|
54
|
-
RT -->|"autre
|
|
55
|
-
J --> QP["
|
|
54
|
+
RT -->|"text/* · autre · sans Content-Type"| B["ParserRaw"]
|
|
55
|
+
J --> QP["body<br/>@Body() · this.body"]
|
|
56
56
|
Q --> QP
|
|
57
57
|
X --> QP
|
|
58
|
-
M --> QF["
|
|
58
|
+
M --> QF["files<br/>@UploadedFiles() · this.files"]
|
|
59
59
|
M --> QP
|
|
60
|
-
B -->
|
|
60
|
+
B --> RAW["body : chaîne (text/*)<br/>ou octets Uint8Array (le reste)"]
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Trois idées portent tout le reste :
|
|
64
64
|
|
|
65
|
-
1. **Le `Content-Type` décide, pas la méthode.** `parseRequest()` (`context/http/Request.ts
|
|
66
|
-
aiguille vers `ParserJson` / `ParserQs` / `ParserXml` / **busboy** /
|
|
65
|
+
1. **Le `Content-Type` décide, pas la méthode.** `parseRequest()` (`context/http/Request.ts#HttpRequest.parseRequest`)
|
|
66
|
+
aiguille vers `ParserJson` / `ParserQs` / `ParserXml` / **busboy** / `ParserRaw` selon l'en-tête.
|
|
67
|
+
Ce dernier livre un corps qu'il ne décode pas **tel qu'il est reçu** : une chaîne pour `text/*`
|
|
68
|
+
(au `charset` déclaré), des **octets** (`Uint8Array`, sans copie) pour tout autre type et pour un
|
|
69
|
+
corps sans `Content-Type` (RFC 9110 §8.3 → `application/octet-stream`) — jamais un `{}` qui
|
|
70
|
+
mentirait. Un corps vide laisse `body` inchangé (`context/http/parser.ts#ParserRaw`).
|
|
67
71
|
2. **Seul le multipart écrit sur disque.** Les fichiers sont **streamés** au fil de l'eau vers un
|
|
68
|
-
fichier temporaire (`parseMultipart()`, `context/http/Request.ts
|
|
72
|
+
fichier temporaire (`parseMultipart()`, `context/http/Request.ts#HttpRequest.parseMultipart`) — jamais bufferisés en RAM.
|
|
69
73
|
JSON / urlencoded / XML restent en mémoire (petits corps).
|
|
70
74
|
3. **Deux budgets distincts, jamais confondus.** Le corps **non-multipart** est borné par `maxBodySize`
|
|
71
75
|
(défaut **1 MiB**) ; le multipart a **ses propres** limites busboy (`maxFileSize`, `maxTotalFileSize`,
|
|
@@ -80,7 +84,7 @@ Trois idées portent tout le reste :
|
|
|
80
84
|
| `multipart/form-data` | Format d'un formulaire qui contient des **fichiers** : le corps est découpé en _parts_ séparées (RFC 7578). |
|
|
81
85
|
| Boundary | Chaîne-séparateur (`--…`) qui délimite chaque _part_ d'un corps multipart. |
|
|
82
86
|
| Part | Un bloc d'un corps multipart : soit un **champ texte** (`field`), soit un **fichier** (`file`). |
|
|
83
|
-
| Field / File | Champ texte (→ `
|
|
87
|
+
| Field / File | Champ texte (→ `body`) vs fichier uploadé (→ `files`), les deux issus du même corps multipart. |
|
|
84
88
|
| busboy | La bibliothèque de parsing multipart **en flux** (`@fastify/busboy`) : elle lit le corps sans le charger en RAM. |
|
|
85
89
|
| Streaming (au fil de l'eau) | Écrire le fichier sur disque **pendant** sa réception, octet par octet — au lieu d'attendre le corps entier. |
|
|
86
90
|
| Fichier temporaire (temp) | Le fichier écrit par busboy dans `uploadDir`, nommé par un UUID. Le contrôleur le déplace ou le laisse expirer. |
|
|
@@ -99,13 +103,13 @@ Imagine un **guichet de dépôt**. Le client arrive avec une enveloppe (`Content
|
|
|
99
103
|
formulaire à plat (JSON, urlencodé, XML) que le guichetier **recopie sur une fiche** en mémoire, soit un
|
|
100
104
|
**colis** (multipart) qu'il pose **directement dans une consigne** (le disque) sans jamais le tenir à
|
|
101
105
|
bout de bras. Dans les deux cas, ton contrôleur ne voit pas l'enveloppe : il reçoit la fiche
|
|
102
|
-
(`
|
|
106
|
+
(`body`) et le bordereau de consigne (`files`).
|
|
103
107
|
|
|
104
108
|
Concrètement, la couche a quatre responsabilités, et rien d'autre :
|
|
105
109
|
|
|
106
110
|
1. **Choisir le parser** d'après le `Content-Type`.
|
|
107
|
-
2. **Décoder les champs** (JSON, urlencoded, XML) → `
|
|
108
|
-
3. **Streamer les fichiers** multipart vers le disque, sans pic RAM → `
|
|
111
|
+
2. **Décoder les champs** (JSON, urlencoded, XML) → `body`, lus par `@Body()`.
|
|
112
|
+
3. **Streamer les fichiers** multipart vers le disque, sans pic RAM → `files`, lus par
|
|
109
113
|
`@UploadedFiles()`.
|
|
110
114
|
4. **Borner la taille** — rejeter en `413` un corps qui menace la mémoire ou le disque.
|
|
111
115
|
|
|
@@ -117,23 +121,23 @@ Trois choix structurent l'implémentation, et chacun a une raison de sécurité
|
|
|
117
121
|
|
|
118
122
|
**Le multipart ne touche jamais la RAM.** Là où l'ancien chemin bufferisait le corps entier, busboy lit
|
|
119
123
|
le flux et écrit chaque fichier au fil de l'eau dans le dossier temporaire (`streamMultipart()`,
|
|
120
|
-
`context/http/Request.ts
|
|
124
|
+
`context/http/Request.ts#HttpRequest.streamMultipart`). Un upload de 1 Go ne coûte donc pas 1 Go de heap — seuls les petits
|
|
121
125
|
champs texte restent en mémoire. C'est ce qui rend un endpoint d'upload public tenable.
|
|
122
126
|
|
|
123
127
|
**Le nom du fichier temporaire n'est jamais celui du client.** Chaque fichier reçu est écrit sous un nom
|
|
124
|
-
`randomUUID()` + extension d'origine (`context/http/Request.ts
|
|
128
|
+
`randomUUID()` + extension d'origine (`context/http/Request.ts#HttpRequest.streamMultipart`) : un nom malveillant
|
|
125
129
|
(`../../etc/passwd`) ne peut pas influencer le **chemin** d'écriture. Le nom d'origine est conservé en
|
|
126
130
|
**métadonnée** (`filename`), pas dans le chemin.
|
|
127
131
|
|
|
128
132
|
**Deux budgets, secure-by-default.** Le corps non-multipart est plafonné à **1 MiB** par défaut
|
|
129
|
-
(`maxBodySize`, `http/nodefony/config/config.ts
|
|
133
|
+
(`maxBodySize`, `http/nodefony/config/config.ts#httpConfigSchema`) — un `POST` JSON géant est rejeté avant d'être bufferisé. Le
|
|
130
134
|
multipart, lui, a ses propres bornes busboy (par fichier, cumul, nombre) qui coupent le flux et
|
|
131
|
-
nettoient les temporaires déjà posés au moindre dépassement (`context/http/Request.ts
|
|
135
|
+
nettoient les temporaires déjà posés au moindre dépassement (`context/http/Request.ts#HttpRequest.enforceBodyLimit`).
|
|
132
136
|
|
|
133
137
|
> [!IMPORTANT]
|
|
134
138
|
> `@nodefony/http` ne peut pas importer `@nodefony/framework` (cycle). Les **décorateurs**
|
|
135
139
|
> (`@Body`, `@UploadedFiles`…) vivent donc dans `@nodefony/framework`, mais ils lisent les emplacements
|
|
136
|
-
> (`
|
|
140
|
+
> (`body`, `files`) remplis par `@nodefony/http`. Une page, deux modules — c'est le prix du
|
|
137
141
|
> découplage.
|
|
138
142
|
|
|
139
143
|
## 🚀 Démarrage rapide
|
|
@@ -143,30 +147,29 @@ n'écris que tes bornes (si les défauts ne conviennent pas) et le contrôleur q
|
|
|
143
147
|
|
|
144
148
|
### 1. Les bornes de payload
|
|
145
149
|
|
|
146
|
-
Réglées sur le module `@nodefony/http`,
|
|
147
|
-
sont facultatives : ce sont les défauts du schéma, écrits ici pour
|
|
150
|
+
Réglées sur le module `@nodefony/http`, dans son fragment `nodefony/config/http.ts` (que le manifeste
|
|
151
|
+
passe à `use()`). Toutes ces clés sont facultatives : ce sont les défauts du schéma, écrits ici pour
|
|
152
|
+
les rendre visibles et les resserrer.
|
|
148
153
|
|
|
149
154
|
```typescript
|
|
150
|
-
// nodefony
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
],
|
|
169
|
-
}));
|
|
155
|
+
// nodefony/config/http.ts — bornes de payload de l'application
|
|
156
|
+
import type { IHttpConfigInput } from "@nodefony/http";
|
|
157
|
+
|
|
158
|
+
export const httpConfig = () =>
|
|
159
|
+
({
|
|
160
|
+
// Corps NON-multipart (JSON, urlencoded, XML, brut) : 1 MiB par défaut.
|
|
161
|
+
// Au-delà → 413 « Content Too Large ». 0 = illimité.
|
|
162
|
+
maxBodySize: 1_048_576,
|
|
163
|
+
upload: {
|
|
164
|
+
// Chaque fichier (busboy limits.fileSize) — dépassement → 413.
|
|
165
|
+
maxFileSize: 10 * 1024 * 1024, // 10 MiB
|
|
166
|
+
// Cumul de TOUS les fichiers d'une même requête (compteur Nodefony).
|
|
167
|
+
maxTotalFileSize: 30 * 1024 * 1024, // 30 MiB
|
|
168
|
+
maxFiles: 10, // nombre de fichiers — anti-DoS
|
|
169
|
+
// Intégrité optionnelle : hash calculé PENDANT le stream (0 relecture).
|
|
170
|
+
hashAlgorithm: "sha256",
|
|
171
|
+
},
|
|
172
|
+
}) satisfies IHttpConfigInput;
|
|
170
173
|
```
|
|
171
174
|
|
|
172
175
|
### 2. Le contrôleur qui reçoit l'upload
|
|
@@ -232,7 +235,7 @@ curl -k -X POST -H "content-type: application/json" \
|
|
|
232
235
|
## 🏗️ Architecture interne — le trajet d'un corps multipart
|
|
233
236
|
|
|
234
237
|
Une requête multipart traverse `HttpRequest` → busboy → disque → `UploadService`, puis atterrit dans le
|
|
235
|
-
contrôleur avec `
|
|
238
|
+
contrôleur avec `files`/`body` déjà remplis.
|
|
236
239
|
|
|
237
240
|
```mermaid
|
|
238
241
|
sequenceDiagram
|
|
@@ -246,30 +249,30 @@ sequenceDiagram
|
|
|
246
249
|
R->>R: parseRequest() — content-type = multipart
|
|
247
250
|
R->>B: request.pipe(busboy)
|
|
248
251
|
B->>D: chaque fichier → <uuid>.<ext> (au fil de l'eau)
|
|
249
|
-
B-->>R: field (texte) →
|
|
252
|
+
B-->>R: field (texte) → body
|
|
250
253
|
Note over B,D: limites fileSize / total / files → 413 + cleanup des temp
|
|
251
254
|
B->>R: finish
|
|
252
255
|
R->>U: createUploadFile() → UploadedFile (stat async)
|
|
253
|
-
R->>Ctrl: onRequestEnd —
|
|
256
|
+
R->>Ctrl: onRequestEnd — files / body prêts
|
|
254
257
|
Ctrl->>D: file.moveAsync(dest)
|
|
255
258
|
```
|
|
256
259
|
|
|
257
260
|
Les points d'implémentation qui expliquent des comportements surprenants :
|
|
258
261
|
|
|
259
262
|
1. **L'aiguillage lit le `Content-Type`, pas la méthode** — `parseRequest()`
|
|
260
|
-
(`context/http/Request.ts
|
|
261
|
-
des méthodes parsées (`context/http/Request.ts
|
|
263
|
+
(`context/http/Request.ts#HttpRequest.parseRequest`). `PATCH` porte un corps comme `POST`/`PUT` : il figure dans la table
|
|
264
|
+
des méthodes parsées (`context/http/Request.ts#parse`) — l'oubli laissait tout `PATCH` avec un corps vide.
|
|
262
265
|
2. **Le multipart draine sur un `finish`, après flush de tous les writes** — `streamMultipart()`
|
|
263
|
-
(`context/http/Request.ts
|
|
264
|
-
qu'une fois tous les fichiers fermés (`bb.on("finish")`, `context/http/Request.ts
|
|
266
|
+
(`context/http/Request.ts#HttpRequest.streamMultipart`) accumule les `Promise` d'écriture disque et ne résout `{ fields, files }`
|
|
267
|
+
qu'une fois tous les fichiers fermés (`bb.on("finish")`, `context/http/Request.ts#HttpRequest.streamMultipart`).
|
|
265
268
|
3. **Une limite dépassée nettoie les temporaires déjà posés** — `abort()`
|
|
266
|
-
(`context/http/Request.ts
|
|
267
|
-
déjà écrits (`context/http/Request.ts
|
|
269
|
+
(`context/http/Request.ts#HttpRequest.streamMultipart`) délie le flux, détruit les write-streams ouverts et `unlink` les temp
|
|
270
|
+
déjà écrits (`context/http/Request.ts#unlinkQuiet`) avant de rejeter en `413` : pas d'orphelins sur le disque.
|
|
268
271
|
4. **Les autres formats drainent AVANT de concaténer** — la base `Parser.parse()` attend `end`
|
|
269
|
-
(`context/http/parser.ts
|
|
270
|
-
lisaient un corps partiel → `
|
|
272
|
+
(`context/http/parser.ts#Parser.parse`) avant `Buffer.concat` : sans ce drain, `ParserQs`/`ParserXml`
|
|
273
|
+
lisaient un corps partiel → `body` vide (bug de régression, cf tests).
|
|
271
274
|
5. **Un multipart sans boundary exploitable ne crashe pas** — `new Busboy()` lève **synchroniquement** ;
|
|
272
|
-
c'est rattrapé et on bascule sur le `Parser` brut (`context/http/Request.ts
|
|
275
|
+
c'est rattrapé et on bascule sur le `Parser` brut (`context/http/Request.ts#HttpRequest.parser`).
|
|
273
276
|
|
|
274
277
|
## ⚙️ Configuration — deux budgets, jamais confondus
|
|
275
278
|
|
|
@@ -283,10 +286,10 @@ non-multipart n'écoute pas `upload.*`.
|
|
|
283
286
|
| `maxBodySize` | octets | `1_048_576` (1 MiB) | Plafond d'un corps **JSON / urlencoded / XML / brut** → `413`. `0` = illimité. |
|
|
284
287
|
|
|
285
288
|
Deux rideaux, tous deux `runtimeMutable` (éditable à chaud) : un **pré-check** sur `Content-Length`
|
|
286
|
-
qui rejette **avant** de lire (`enforceBodyLimit()`, `context/http/Request.ts
|
|
289
|
+
qui rejette **avant** de lire (`enforceBodyLimit()`, `context/http/Request.ts#HttpRequest.enforceBodyLimit`), puis un **compteur
|
|
287
290
|
en streaming** qui coupe le socket si le corps déborde sans `Content-Length` honnête — chunked ou
|
|
288
|
-
menteur (`Parser.write()`, `context/http/parser.ts
|
|
289
|
-
Champ `maxBodySize` du schéma : `config/config.ts
|
|
291
|
+
menteur (`Parser.write()`, `context/http/parser.ts#Parser.write`, dépassement `context/http/parser.ts#Parser.write`).
|
|
292
|
+
Champ `maxBodySize` du schéma : `config/config.ts#httpConfigSchema`.
|
|
290
293
|
|
|
291
294
|
**Par requête** : `maxBodySize` et les limites `upload.*` (hors `uploadDir`) sont sur la liste blanche
|
|
292
295
|
du calque de configuration — une application peut les changer pour une requête
|
|
@@ -296,22 +299,22 @@ sont lus au moment de lire le corps (`Request.readBodyLimits()`), donc un calque
|
|
|
296
299
|
|
|
297
300
|
### Fichiers multipart — `upload.*`
|
|
298
301
|
|
|
299
|
-
Table dérivée de `uploadSchema` (`config/config.ts
|
|
302
|
+
Table dérivée de `uploadSchema` (`config/config.ts#uploadSchema`). Toutes les bornes sont `runtimeMutable`.
|
|
300
303
|
|
|
301
|
-
| Option | Type | Défaut | Effet
|
|
302
|
-
| ------------------ | -------------------------------- | ---------------------- |
|
|
303
|
-
| `uploadDir` | chemin | `""` → `kernel.tmpDir` | Dossier de dépôt des temporaires. Vide = temp du kernel (`config/config.ts
|
|
304
|
-
| `maxFileSize` | octets | `524_288_000` (500 MB) | Taille max d'**UN** fichier (busboy `limits.fileSize`) → `413` (`config/config.ts
|
|
305
|
-
| `maxTotalFileSize` | octets | `524_288_000` (500 MB) | Taille **CUMULÉE** des fichiers d'une requête (compteur Nodefony) → `413` (`config/config.ts
|
|
306
|
-
| `maxFiles` | entier | `1000` | Nombre max de fichiers (busboy `limits.files`) → `413` (`config/config.ts
|
|
307
|
-
| `maxFields` | entier | `1000` | Nombre max de champs texte (busboy `limits.fields`) → `413` (`config/config.ts
|
|
308
|
-
| `maxFieldsSize` | octets | `2_097_152` (2 MB) | Taille max d'un champ texte (busboy `limits.fieldSize`) (`config/config.ts
|
|
309
|
-
| `hashAlgorithm` | `false` \| `sha256`/`sha1`/`md5` | `false` | Hash calculé pendant le stream (intégrité) (`config/config.ts
|
|
310
|
-
| `encoding` | chaîne | `"utf-8"` | Encodage par défaut des champs texte (busboy `defCharset`) (`config/config.ts
|
|
304
|
+
| Option | Type | Défaut | Effet |
|
|
305
|
+
| ------------------ | -------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
306
|
+
| `uploadDir` | chemin | `""` → `kernel.tmpDir` | Dossier de dépôt des temporaires. Vide = temp du kernel (`config/config.ts#uploadSchema`). |
|
|
307
|
+
| `maxFileSize` | octets | `524_288_000` (500 MB) | Taille max d'**UN** fichier (busboy `limits.fileSize`) → `413` (`config/config.ts#uploadSchema`). |
|
|
308
|
+
| `maxTotalFileSize` | octets | `524_288_000` (500 MB) | Taille **CUMULÉE** des fichiers d'une requête (compteur Nodefony) → `413` (`config/config.ts#uploadSchema`). |
|
|
309
|
+
| `maxFiles` | entier | `1000` | Nombre max de fichiers (busboy `limits.files`) → `413` (`config/config.ts#uploadSchema`). |
|
|
310
|
+
| `maxFields` | entier | `1000` | Nombre max de champs texte (busboy `limits.fields`) → `413` (`config/config.ts#uploadSchema`). |
|
|
311
|
+
| `maxFieldsSize` | octets | `2_097_152` (2 MB) | Taille max d'un champ texte (busboy `limits.fieldSize`) (`config/config.ts#uploadSchema`). |
|
|
312
|
+
| `hashAlgorithm` | `false` \| `sha256`/`sha1`/`md5` | `false` | Hash calculé pendant le stream (intégrité) (`config/config.ts#uploadSchema`). |
|
|
313
|
+
| `encoding` | chaîne | `"utf-8"` | Encodage par défaut des champs texte (busboy `defCharset`) (`config/config.ts#uploadSchema`). |
|
|
311
314
|
|
|
312
315
|
> [!WARNING]
|
|
313
316
|
> `uploadDir` vide est **résolu au boot** sur le répertoire temporaire du kernel par le builder de config
|
|
314
|
-
> (`defineModuleConfig.ts
|
|
317
|
+
> (`defineModuleConfig.ts#applyKernelDefaults`) : un défaut vide n'est jamais un chemin vide en runtime. Ne dérérérence
|
|
315
318
|
> **jamais** le kernel au top-level d'un `config.ts` — c'est la raison du champ marqué `kernelDerived`.
|
|
316
319
|
|
|
317
320
|
## 🧰 API — accéder aux champs et aux fichiers
|
|
@@ -320,34 +323,34 @@ Depuis un contrôleur, deux surfaces équivalentes : les **décorateurs de param
|
|
|
320
323
|
recommandé) et les **getters** de `Controller` (impératif). Les signatures exactes vivent dans
|
|
321
324
|
`.ai/symbols.json` — jamais recopiées ici.
|
|
322
325
|
|
|
323
|
-
| Accès | Source lue
|
|
324
|
-
| -------------------------------------------------- |
|
|
325
|
-
| `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`
|
|
326
|
-
| `@UploadedFile() f: IUploadedFile` | le **premier** fichier
|
|
327
|
-
| `@Body() body` | tous les champs parsés (`
|
|
328
|
-
| `@Body("label") v` | un seul champ du body
|
|
329
|
-
| `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté**
|
|
330
|
-
| `this.
|
|
331
|
-
| `this.
|
|
326
|
+
| Accès | Source lue | Ancrage |
|
|
327
|
+
| -------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------- |
|
|
328
|
+
| `@UploadedFiles() f: IUploadedFile[]` | tous les fichiers (`files`) | `resolveParamArg` `"files"` (`routerDecorators.ts#UploadedFiles`) |
|
|
329
|
+
| `@UploadedFile() f: IUploadedFile` | le **premier** fichier | `resolveParamArg` `"file"` (`routerDecorators.ts#UploadedFile`) |
|
|
330
|
+
| `@Body() body` | tous les champs parsés (`body`) | `resolveParamArg` `"body"` (`routerDecorators.ts#Body`) |
|
|
331
|
+
| `@Body("label") v` | un seul champ du body | même source, clé (`routerDecorators.ts#Body`) |
|
|
332
|
+
| `@Body({ stream: true }) s: NodeJS.ReadableStream` | le **flux brut**, parse **sauté** | `resolveParamArg` stream (`routerDecorators.ts#resolveParamArg`) |
|
|
333
|
+
| `this.files` | équivalent getter des fichiers | `Controller.files` (`framework/nodefony/src/Controller.ts#Controller.files`) |
|
|
334
|
+
| `this.body` | équivalent getter des champs | `Controller.body` (`framework/nodefony/src/Controller.ts#Controller.body`) |
|
|
332
335
|
|
|
333
336
|
Les décorateurs `@UploadedFile` / `@UploadedFiles` sont des fabriques de paramètre
|
|
334
|
-
(`routerDecorators.ts
|
|
335
|
-
`IParsedUploadFile` viennent de `@nodefony/http` (`interfaces/IUpload.ts
|
|
337
|
+
(`routerDecorators.ts#UploadedFiles`), exportées par `@nodefony/framework` ; leurs interfaces `IUploadedFile` /
|
|
338
|
+
`IParsedUploadFile` viennent de `@nodefony/http` (`interfaces/IUpload.ts#IUploadedFile`, `interfaces/IUpload.ts#IParsedUploadFile`).
|
|
336
339
|
|
|
337
340
|
### Un fichier uploadé — `UploadedFile`
|
|
338
341
|
|
|
339
|
-
Chaque entrée de `
|
|
342
|
+
Chaque entrée de `files` est un `UploadedFile` (`service/upload/upload-service.ts#UploadedFile`), déjà écrit
|
|
340
343
|
sur disque. Ses membres utiles :
|
|
341
344
|
|
|
342
|
-
| Membre | Ce qu'il donne | Ancrage
|
|
343
|
-
| ------------------------ | ----------------------------------------------------------------------- |
|
|
344
|
-
| `filename` | Nom **d'origine** déclaré par le client (métadonnée, jamais le chemin). | `realName()` (`service/upload/upload-service.ts
|
|
345
|
-
| `size` | Taille réellement écrite (octets). | `getSize()` (`service/upload/upload-service.ts
|
|
346
|
-
| `prettySize` | Taille lisible (`« 1.2 MB »`). | `getPrettySize()` (`service/upload/upload-service.ts
|
|
347
|
-
| `mimeType` | Type MIME déclaré (`image/png`…), sinon deviné de l'extension. | `getMimeType()` (`service/upload/upload-service.ts
|
|
348
|
-
| `hash` / `hashAlgorithm` | Empreinte d'intégrité si `upload.hashAlgorithm` est réglé. | `interfaces/IUpload.ts
|
|
349
|
-
| `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts
|
|
350
|
-
| `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts
|
|
345
|
+
| Membre | Ce qu'il donne | Ancrage |
|
|
346
|
+
| ------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
347
|
+
| `filename` | Nom **d'origine** déclaré par le client (métadonnée, jamais le chemin). | `realName()` (`service/upload/upload-service.ts#UploadedFile.realName`) |
|
|
348
|
+
| `size` | Taille réellement écrite (octets). | `getSize()` (`service/upload/upload-service.ts#UploadedFile.getSize`) |
|
|
349
|
+
| `prettySize` | Taille lisible (`« 1.2 MB »`). | `getPrettySize()` (`service/upload/upload-service.ts#UploadedFile.getPrettySize`) |
|
|
350
|
+
| `mimeType` | Type MIME déclaré (`image/png`…), sinon deviné de l'extension. | `getMimeType()` (`service/upload/upload-service.ts#UploadedFile.getMimeType`) |
|
|
351
|
+
| `hash` / `hashAlgorithm` | Empreinte d'intégrité si `upload.hashAlgorithm` est réglé. | `interfaces/IUpload.ts#IUploadedFile.hashAlgorithm` |
|
|
352
|
+
| `moveAsync(target)` | Déplace le temp — **non bloquant, recommandé** dans le pipeline. | `moveAsync()` (`service/upload/upload-service.ts#UploadedFile.moveAsync`) |
|
|
353
|
+
| `move(target)` | Variante **synchrone** (compat) — bloque l'event-loop. | `move()` (`service/upload/upload-service.ts#UploadedFile.move`) |
|
|
351
354
|
|
|
352
355
|
`move`/`moveAsync` acceptent un **fichier cible** ou un **dossier existant** : sur un dossier, la
|
|
353
356
|
destination est bâtie avec `filename` — d'où l'avertissement de sécurité ci-dessous.
|
|
@@ -357,7 +360,7 @@ destination est bâtie avec `filename` — d'où l'avertissement de sécurité c
|
|
|
357
360
|
Pour piper directement un très gros corps (vidéo, backup) vers le disque ou S3 sans passer par busboy ni
|
|
358
361
|
par la RAM, `@Body({ stream: true })` court-circuite le parse et injecte l'`IncomingMessage` brut (un
|
|
359
362
|
`Readable`). Le pipeline sait le sauter en amont via `routeExpectsBodyStream()`
|
|
360
|
-
(`routerDecorators.ts
|
|
363
|
+
(`routerDecorators.ts#routeExpectsBodyStream`), mémoïsé sur la route.
|
|
361
364
|
|
|
362
365
|
```ts
|
|
363
366
|
// fragment — le contrôleur pipe le flux lui-même (0 parse, 0 pic RAM)
|
|
@@ -376,15 +379,15 @@ piégé. Les défenses en place, et **ce qui reste à ta charge**.
|
|
|
376
379
|
<!-- prettier-ignore -->
|
|
377
380
|
| Menace | Défense côté framework | À ta charge |
|
|
378
381
|
| --- | --- | --- |
|
|
379
|
-
| **Path traversal** (chemin d'écriture) | Le temp est nommé `randomUUID()` + extension — jamais le nom client (`context/http/Request.ts
|
|
382
|
+
| **Path traversal** (chemin d'écriture) | Le temp est nommé `randomUUID()` + extension — jamais le nom client (`context/http/Request.ts#HttpRequest.streamMultipart`). | La **destination** de `move()` (voir avertissement). |
|
|
380
383
|
| **Saturation RAM** | Multipart streamé (jamais bufferisé) ; corps non-multipart borné (`maxBodySize`). | Resserrer `maxBodySize` selon l'endpoint. |
|
|
381
|
-
| **Saturation disque** | `maxFileSize` + `maxTotalFileSize` + `maxFiles` ; `abort()` nettoie les temp à l'abandon (`context/http/Request.ts
|
|
382
|
-
| **DoS par quantité** | `maxFields` / `maxFiles` / `parts` → `413` (`context/http/Request.ts
|
|
384
|
+
| **Saturation disque** | `maxFileSize` + `maxTotalFileSize` + `maxFiles` ; `abort()` nettoie les temp à l'abandon (`context/http/Request.ts#HttpRequest.streamMultipart`). | Purger les temp non déplacés (TTL / cron). |
|
|
385
|
+
| **DoS par quantité** | `maxFields` / `maxFiles` / `parts` → `413` (`context/http/Request.ts#HttpRequest.streamMultipart`). | — |
|
|
383
386
|
| **Type de fichier hostile** | `mimeType` **déclaré** est exposé tel quel. | Valider le type/contenu réel (le MIME client est déclaratif). |
|
|
384
387
|
|
|
385
388
|
> [!WARNING]
|
|
386
389
|
> **Path traversal sur la destination.** `move(dir)` / `moveAsync(dir)` vers un **dossier** construit le
|
|
387
|
-
> chemin final avec `file.filename` — le nom **client** (`service/upload/upload-service.ts
|
|
390
|
+
> chemin final avec `file.filename` — le nom **client** (`service/upload/upload-service.ts#UploadedFile.filename`). Un nom
|
|
388
391
|
> `../../etc/cron.d/x` s'échapperait du dossier. Le framework protège le chemin du **temporaire**, pas
|
|
389
392
|
> celui que **tu** choisis. Règle : passe une **cible complète** que tu contrôles, ou assainis toujours
|
|
390
393
|
> avec `path.basename(file.filename)` — exactement le `safeName` du Démarrage rapide.
|
|
@@ -394,14 +397,14 @@ piégé. Les défenses en place, et **ce qui reste à ta charge**.
|
|
|
394
397
|
Le parsing du corps est sur le chemin de chaque requête écrivante — les choix visibles dans le code :
|
|
395
398
|
|
|
396
399
|
- **Multipart en flux pur** — plus de double-bufferisation : busboy écrit sur disque au fil de l'eau,
|
|
397
|
-
seuls les champs texte restent en mémoire (`context/http/Request.ts
|
|
400
|
+
seuls les champs texte restent en mémoire (`context/http/Request.ts#HttpRequest.initialize`).
|
|
398
401
|
- **Rejet AVANT lecture** — le pré-check `Content-Length` renvoie `413` sans lire un octet
|
|
399
|
-
(`context/http/Request.ts
|
|
400
|
-
bufferisée au dépassement (`context/http/parser.ts
|
|
402
|
+
(`context/http/Request.ts#HttpRequest.readBodyLimits`) ; le compteur streaming **libère immédiatement** la RAM déjà
|
|
403
|
+
bufferisée au dépassement (`context/http/parser.ts#Parser.write`).
|
|
401
404
|
- **`stat` non bloquant** — `UploadedFile.create()` résout les stats du fichier en async
|
|
402
|
-
(`service/upload/upload-service.ts
|
|
405
|
+
(`service/upload/upload-service.ts#UploadedFile.create`), plus de `lstatSync` par fichier uploadé.
|
|
403
406
|
- **Listeners jumeaux nettoyés** — le drain de fin de corps retire ses écouteurs `end`/`error`/overflow
|
|
404
|
-
à la main (`once` n'auto-détache que celui qui fire) (`context/http/parser.ts
|
|
407
|
+
à la main (`once` n'auto-détache que celui qui fire) (`context/http/parser.ts#Parser.ended`).
|
|
405
408
|
- **Hash opt-in** — `hashAlgorithm: false` par défaut : zéro coût CPU tant que l'intégrité n'est pas
|
|
406
409
|
demandée.
|
|
407
410
|
|
|
@@ -410,28 +413,28 @@ pipeline : `npm run test:memory` (skill `nodefony-check-memory-health`).
|
|
|
410
413
|
|
|
411
414
|
## 📜 Normes appliquées
|
|
412
415
|
|
|
413
|
-
| Domaine | Norme | Ancrage
|
|
414
|
-
| ---------------------------------- | -------------------------------- |
|
|
415
|
-
| Formulaire avec fichiers | RFC 7578 (`multipart/form-data`) | `parseMultipart()` via busboy (`context/http/Request.ts
|
|
416
|
-
| Corps trop gros → 413 | RFC 9110 §15.5.14 | `enforceBodyLimit()` (`context/http/Request.ts
|
|
417
|
-
| 413 en streaming (chunked/menteur) | RFC 9110 §15.5.14 | `Parser.write()` (`context/http/parser.ts
|
|
418
|
-
| Bornes multipart → 413 | RFC 9110 §15.5.14 | `stream.on("limit")` (`context/http/Request.ts
|
|
419
|
-
| Défense path traversal (nom temp) | OWASP — File Upload | `randomUUID()` (`context/http/Request.ts
|
|
420
|
-
| Charset du corps honoré | RFC 9110 (Content-Type) | `getCharset()` (`context/http/Request.ts
|
|
416
|
+
| Domaine | Norme | Ancrage |
|
|
417
|
+
| ---------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------ |
|
|
418
|
+
| Formulaire avec fichiers | RFC 7578 (`multipart/form-data`) | `parseMultipart()` via busboy (`context/http/Request.ts#HttpRequest.parseMultipart`) |
|
|
419
|
+
| Corps trop gros → 413 | RFC 9110 §15.5.14 | `enforceBodyLimit()` (`context/http/Request.ts#HttpRequest.enforceBodyLimit`) |
|
|
420
|
+
| 413 en streaming (chunked/menteur) | RFC 9110 §15.5.14 | `Parser.write()` (`context/http/parser.ts#Parser.write`) |
|
|
421
|
+
| Bornes multipart → 413 | RFC 9110 §15.5.14 | `stream.on("limit")` (`context/http/Request.ts#HttpRequest.enforceBodyLimit`) |
|
|
422
|
+
| Défense path traversal (nom temp) | OWASP — File Upload | `randomUUID()` (`context/http/Request.ts#HttpRequest.streamMultipart`) |
|
|
423
|
+
| Charset du corps honoré | RFC 9110 (Content-Type) | `getCharset()` (`context/http/Request.ts#HttpRequest.getCharset`) |
|
|
421
424
|
|
|
422
425
|
## ⚠️ Pièges
|
|
423
426
|
|
|
424
|
-
| Symptôme | Cause
|
|
425
|
-
| ------------------------------------------------------- |
|
|
426
|
-
| `@Body()` est vide sur un upload de fichier | Les fichiers vont dans `
|
|
427
|
-
| Un `POST` JSON de 1,5 Mo est refusé en `413` | `maxBodySize` vaut **1 MiB** par défaut
|
|
428
|
-
| Un gros fichier passe alors que `maxBodySize` est petit | Le multipart **n'écoute pas** `maxBodySize`
|
|
429
|
-
| `413` sur upload sans message clair | Une borne busboy atteinte en streaming (fichier, cumul, nombre)
|
|
430
|
-
| Un fichier écrit `../../etc/…` après un `move` | `move(dossier)` utilise le nom **client** (`upload-service.ts
|
|
431
|
-
| Des fichiers temporaires s'accumulent dans `uploadDir` | Le contrôleur ne déplace jamais le temp
|
|
432
|
-
| `
|
|
433
|
-
| Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré
|
|
434
|
-
| `multipart` sans boundary fait planter | `new Busboy()` throw synchrone
|
|
427
|
+
| Symptôme | Cause | Correction |
|
|
428
|
+
| ------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
429
|
+
| `@Body()` est vide sur un upload de fichier | Les fichiers vont dans `files`, **pas** dans `body` | Lire les fichiers avec `@UploadedFiles()` / `this.files` ; `@Body()` = champs texte |
|
|
430
|
+
| Un `POST` JSON de 1,5 Mo est refusé en `413` | `maxBodySize` vaut **1 MiB** par défaut | Augmenter `maxBodySize` (pas `upload.maxFileSize`) |
|
|
431
|
+
| Un gros fichier passe alors que `maxBodySize` est petit | Le multipart **n'écoute pas** `maxBodySize` | Régler `upload.maxFileSize` / `maxTotalFileSize` |
|
|
432
|
+
| `413` sur upload sans message clair | Une borne busboy atteinte en streaming (fichier, cumul, nombre) | Vérifier les bornes `upload.*` — 413 émis sur `stream.on("limit")` (`context/http/Request.ts#HttpRequest.enforceBodyLimit`) |
|
|
433
|
+
| Un fichier écrit `../../etc/…` après un `move` | `move(dossier)` utilise le nom **client** (`upload-service.ts#UploadedFile.move`) | Passer une cible complète, ou `path.basename(file.filename)` |
|
|
434
|
+
| Des fichiers temporaires s'accumulent dans `uploadDir` | Le contrôleur ne déplace jamais le temp | Appeler `moveAsync()` (ou purger l'ancien temp par TTL) |
|
|
435
|
+
| `body` vide sur `PATCH` | Déjà géré : `PATCH` est dans la table des méthodes parsées | Aucune — corps `PATCH` parsé comme `POST` (`context/http/Request.ts#parse`) |
|
|
436
|
+
| Corps `latin1` mal décodé | Déjà géré : le `charset=` du `Content-Type` est honoré | Aucune — `getCharset()` normalise (`context/http/Request.ts#HttpRequest.getCharset`) |
|
|
437
|
+
| `multipart` sans boundary fait planter | `new Busboy()` throw synchrone | Déjà géré : bascule sur le parser brut (`context/http/Request.ts#HttpRequest.parseRequest`) |
|
|
435
438
|
|
|
436
439
|
## 🧪 Tests & couverture
|
|
437
440
|
|
|
@@ -443,7 +446,7 @@ dans le Markdown).
|
|
|
443
446
|
| Unitaires — `UploadedFile` | `unit/UploadedFile.test.ts` — `create()` async, `move`/`moveAsync`, `mimeType`, `realName` |
|
|
444
447
|
| Unitaires — `@Body` stream | `framework` `unit/BodyStream.test.ts` — `@Body({ stream })`, `resolveParamArg`, `routeExpectsBodyStream` |
|
|
445
448
|
| Intégration — multipart | `http/upload.test.ts` — upload simple, multi-fichiers, mime non-octet, champ + fichier, `413` par fichier et cumulé |
|
|
446
|
-
| Intégration — content-types | `http/body-content-types.test.ts` — JSON, urlencoded, `@Body("champ")`, XML → `
|
|
449
|
+
| Intégration — content-types | `http/body-content-types.test.ts` — JSON, urlencoded, `@Body("champ")`, XML → `body` |
|
|
447
450
|
| Intégration — bornes | `http/body-limit.test.ts` — sous la limite `200`, `Content-Length` > 1 MiB → `413`, chunked > 1 MiB refusé |
|
|
448
451
|
| Intégration — flux brut | `integration/bodyStream.test.ts` — `@Body({ stream })` bout-en-bout (TLS + clair), non-régression `@Body()` |
|
|
449
452
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/http",
|
|
3
|
-
"version": "10.0.0-beta.
|
|
3
|
+
"version": "10.0.0-beta.5",
|
|
4
4
|
"description": "Serveurs HTTP, HTTPS, HTTP/2 et WebSocket natifs pour Nodefony : sessions, contextes de requête, certificats TLS",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"private": false,
|
|
29
29
|
"engines": {
|
|
30
|
-
"node": ">=24.
|
|
30
|
+
"node": ">=24.14.0"
|
|
31
31
|
},
|
|
32
32
|
"keywords": [
|
|
33
33
|
"nodefony",
|
|
@@ -51,12 +51,11 @@
|
|
|
51
51
|
"test": "./nodefony/tests"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@fastify/busboy": "3.2.
|
|
54
|
+
"@fastify/busboy": "3.2.4",
|
|
55
55
|
"cookie": "2.0.1",
|
|
56
|
-
"http-terminator": "3.2.0",
|
|
57
56
|
"mime-types": "3.0.2",
|
|
58
57
|
"ms": "^2.1.3",
|
|
59
|
-
"qs": "^6.16.
|
|
58
|
+
"qs": "^6.16.1",
|
|
60
59
|
"serve-static": "2.2.1",
|
|
61
60
|
"tslib": "2.8.1",
|
|
62
61
|
"ws": "8.22.0",
|
|
@@ -72,16 +71,16 @@
|
|
|
72
71
|
"@types/websocket": "1.0.10",
|
|
73
72
|
"@types/ws": "8.18.2",
|
|
74
73
|
"@types/xml2js": "0.4.14",
|
|
75
|
-
"@vitest/coverage-v8": "5.0.
|
|
76
|
-
"nodefony": "^10.0.0-beta.
|
|
74
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
75
|
+
"nodefony": "^10.0.0-beta.5",
|
|
77
76
|
"tsx": "4.23.15",
|
|
78
|
-
"vitest": "5.0.
|
|
77
|
+
"vitest": "5.0.5"
|
|
79
78
|
},
|
|
80
79
|
"license": "Apache-2.0",
|
|
81
80
|
"readmeFilename": "README.md",
|
|
82
81
|
"contributors": [],
|
|
83
82
|
"peerDependencies": {
|
|
84
|
-
"nodefony": "^10.0.0-beta.
|
|
83
|
+
"nodefony": "^10.0.0-beta.5",
|
|
85
84
|
"zod": "^4.6.7"
|
|
86
85
|
},
|
|
87
86
|
"files": [
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateMetadata.js
|
|
2
|
-
function __decorateMetadata(k, v) {
|
|
3
|
-
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
|
-
}
|
|
5
|
-
//#endregion
|
|
6
|
-
export { __decorateMetadata as default };
|