@nodefony/http 10.0.0-beta.4 → 10.0.0-beta.6
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.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +29 -25
- package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
- package/dist/nodefony/config/config.js +6 -3
- package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +17 -3
- package/dist/nodefony/service/audit-logger.js +3 -17
- package/dist/nodefony/service/error-renderer.js +6 -4
- package/dist/nodefony/service/http-kernel.js +191 -40
- package/dist/nodefony/service/pretty-request-logger.js +11 -10
- package/dist/nodefony/service/request-logger.js +1 -1
- package/dist/nodefony/service/reverse-proxy.js +7 -2
- package/dist/nodefony/service/servers/clientError.js +28 -4
- package/dist/nodefony/service/servers/server-h2c.js +5 -5
- package/dist/nodefony/service/servers/server-http.js +5 -3
- package/dist/nodefony/service/servers/server-https.js +12 -5
- package/dist/nodefony/service/servers/server-static.js +1 -6
- package/dist/nodefony/service/servers/server-websocket-secure.js +6 -10
- package/dist/nodefony/service/servers/server-websocket.js +6 -10
- package/dist/nodefony/service/servers/serverShutdown.js +63 -4
- package/dist/nodefony/service/sessions/sessions-service.js +3 -3
- 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 +56 -13
- package/dist/nodefony/src/context/http/ByteStream.js +359 -0
- package/dist/nodefony/src/context/http/HttpContext.js +75 -25
- package/dist/nodefony/src/context/http/Request.js +15 -6
- package/dist/nodefony/src/context/http/Response.js +354 -35
- package/dist/nodefony/src/context/http/SseStream.js +81 -146
- package/dist/nodefony/src/context/http/StreamLifecycle.js +273 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
- package/dist/nodefony/src/context/http2/Request.js +1 -1
- package/dist/nodefony/src/context/http2/Response.js +28 -5
- package/dist/nodefony/src/context/requestTrace.js +104 -0
- package/dist/nodefony/src/context/timingMode.js +21 -0
- package/dist/nodefony/src/context/transport.js +20 -1
- package/dist/nodefony/src/context/unsentResponse.js +30 -0
- package/dist/nodefony/src/context/websocket/Response.js +4 -2
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +81 -20
- 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 +54 -13
- package/dist/nodefony/src/profiler/FrameProfile.js +22 -0
- package/dist/nodefony/src/profiler/Profiler.js +25 -22
- package/dist/nodefony/src/profiler/aggregate.js +26 -6
- package/dist/nodefony/src/proxy/forward.js +109 -9
- package/dist/nodefony/src/proxy/generateProxyConfig.js +6 -2
- package/dist/nodefony/src/proxy/rules.js +29 -14
- package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
- package/dist/types/index.d.ts +8 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
- 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 +16 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +5 -1
- package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
- package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
- package/dist/types/nodefony/service/http-kernel.d.ts +65 -2
- package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +5 -3
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
- package/dist/types/nodefony/src/context/Context.d.ts +25 -1
- package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +60 -4
- package/dist/types/nodefony/src/context/http/Request.d.ts +8 -1
- package/dist/types/nodefony/src/context/http/Response.d.ts +134 -8
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +32 -7
- package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +53 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +17 -3
- 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 +17 -0
- package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +41 -1
- 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/profiler/FrameProfile.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +27 -102
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +23 -106
- package/dist/types/nodefony/src/proxy/forward.d.ts +30 -2
- package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
- package/docs/byte-stream.md +371 -0
- package/docs/cookies.md +13 -3
- package/docs/diagnostics-channel.md +303 -0
- package/docs/index.md +14 -1
- package/docs/observabilite.md +94 -16
- package/docs/reverse-proxy.md +33 -19
- package/docs/servers.md +26 -15
- package/docs/session.md +3 -3
- package/docs/sse.md +27 -20
- package/package.json +16 -16
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { decideBackpressure, nextDropBalance } from "nodefony";
|
|
2
|
+
// nodefony/src/context/websocket/wsFrameRate.ts
|
|
3
|
+
/** Durée d'une fenêtre de comptage (ms) : la borne s'exprime en trames PAR SECONDE. */
|
|
4
|
+
const FRAME_RATE_WINDOW_MS = 1e3;
|
|
5
|
+
let rejectedTotal = 0;
|
|
6
|
+
/**
|
|
7
|
+
* Nombre cumulé de trames entrantes écartées par la borne de débit dans ce processus (unité : une trame reçue).
|
|
8
|
+
*
|
|
9
|
+
* Monotone, `0` tant que la borne est désactivée. Relayé par la sonde du hub
|
|
10
|
+
* temps réel sous le nom figé `frameRateRejectedTotal` (ADR-0021 §S9).
|
|
11
|
+
*
|
|
12
|
+
* @returns le cumul depuis le démarrage du processus
|
|
13
|
+
*/
|
|
14
|
+
const frameRateRejectedTotal = () => rejectedTotal;
|
|
15
|
+
/**
|
|
16
|
+
* Lit une borne numérique dans les options du serveur WebSocket d'un contexte.
|
|
17
|
+
*
|
|
18
|
+
* Sans allocation ni conversion de type : un serveur sans `options` (HTTP) ou
|
|
19
|
+
* une clé absente rendent `0`.
|
|
20
|
+
*/
|
|
21
|
+
const readOption = (server, key) => {
|
|
22
|
+
if (typeof server !== "object" || server === null) return 0;
|
|
23
|
+
if (!("options" in server)) return 0;
|
|
24
|
+
const opts = server.options;
|
|
25
|
+
if (typeof opts !== "object" || opts === null || !(key in opts)) return 0;
|
|
26
|
+
const value = Reflect.get(opts, key);
|
|
27
|
+
return typeof value === "number" ? value : 0;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Borne de débit entrante du serveur WebSocket (`maxFramesPerSecond`) ; `0` = désactivée.
|
|
31
|
+
*
|
|
32
|
+
* Appelée à chaque trame : une lecture de propriété, aucune allocation.
|
|
33
|
+
*
|
|
34
|
+
* @param server - le serveur du contexte (`context.server`)
|
|
35
|
+
* @returns trames admises par seconde, `0` si la borne est désactivée
|
|
36
|
+
*/
|
|
37
|
+
const readFrameRateBound = (server) => readOption(server, "maxFramesPerSecond");
|
|
38
|
+
/**
|
|
39
|
+
* Solde de refus qui ferme la connexion (`frameRateCloseAfterRejects`) ; `0` = jamais.
|
|
40
|
+
*
|
|
41
|
+
* @param server - le serveur du contexte (`context.server`)
|
|
42
|
+
* @returns le solde de fermeture
|
|
43
|
+
*/
|
|
44
|
+
const readFrameRateCloseAfter = (server) => readOption(server, "frameRateCloseAfterRejects");
|
|
45
|
+
/**
|
|
46
|
+
* Décide du sort d'une trame reçue, AVANT tout décodage : l'admettre, l'écarter, ou fermer la connexion.
|
|
47
|
+
*
|
|
48
|
+
* Compte des TRAMES, pas des octets (`maxPayload` borne déjà la taille). La
|
|
49
|
+
* décision est la règle unique du cœur (`decideBackpressure`), appliquée au
|
|
50
|
+
* nombre de trames de la fenêtre : écarter au-delà de la borne, fermer quand le
|
|
51
|
+
* solde de refus atteint `closeAfter` — jamais au premier dépassement. Une
|
|
52
|
+
* trame écartée est comptée dans {@link frameRateRejectedTotal}.
|
|
53
|
+
*
|
|
54
|
+
* @param win - la fenêtre de la connexion (mutée)
|
|
55
|
+
* @param now - l'instant de réception (ms)
|
|
56
|
+
* @param bound - trames admises par seconde (`> 0`)
|
|
57
|
+
* @param closeAfter - solde de refus qui ferme (`0` = jamais)
|
|
58
|
+
* @returns `"send"` (admettre), `"drop"` (écarter) ou `"close"` (fermer en `1008`)
|
|
59
|
+
*/
|
|
60
|
+
const admitFrame = (win, now, bound, closeAfter) => {
|
|
61
|
+
if (now - win.start >= 1e3) {
|
|
62
|
+
win.start = now;
|
|
63
|
+
win.count = 0;
|
|
64
|
+
}
|
|
65
|
+
win.count += 1;
|
|
66
|
+
const verdict = decideBackpressure(win.count, bound, "drop", closeAfter, win.balance);
|
|
67
|
+
win.balance = nextDropBalance(verdict, win.balance);
|
|
68
|
+
if (verdict !== "send") rejectedTotal += 1;
|
|
69
|
+
return verdict;
|
|
70
|
+
};
|
|
71
|
+
export { FRAME_RATE_WINDOW_MS, admitFrame, frameRateRejectedTotal, readFrameRateBound, readFrameRateCloseAfter };
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { binaryPayloadLabel } from "nodefony";
|
|
1
2
|
// nodefony/src/context/websocket/wsLogContent.ts
|
|
2
3
|
/**
|
|
3
|
-
* Formatage **pur** (0 état
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Formatage **pur** (0 état) du CONTENU d'un message WebSocket pour le log de trace
|
|
5
|
+
* (Suivi de requête Studio). Séparé de `WebsocketContext` pour être testable aux
|
|
6
|
+
* limites sans serveur.
|
|
6
7
|
*
|
|
7
8
|
* 🔒 Robustesse binaire (cf doc `ws` — `socket.send(data)` accepte
|
|
8
9
|
* `String | Number | Object | Buffer | ArrayBuffer | TypedArray | DataView |
|
|
9
10
|
* Buffer[] | Blob`, et l'event `message` livre `Buffer | ArrayBuffer | Buffer[]`).
|
|
10
|
-
* **Toute** charge binaire est résumée `[binary N B]` — JAMAIS
|
|
11
|
-
* (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme
|
|
12
|
-
* faux).
|
|
11
|
+
* **Toute** charge binaire est résumée `[binary N B]` — JAMAIS vidée octet par
|
|
12
|
+
* octet (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme
|
|
13
|
+
* et faux). Quand la connexion a négocié un codec de frame (CBOR, ADR-0022 §2), la
|
|
14
|
+
* trame est en plus DÉCODÉE pour être lisible : le débogage se fait à l'observation,
|
|
15
|
+
* jamais en changeant le fil.
|
|
13
16
|
*/
|
|
14
17
|
/** Cap de troncature du contenu loggé (octets/chars). Borne ring + JSONL. */
|
|
15
18
|
const WS_LOG_CONTENT_CAP = 4096;
|
|
@@ -40,28 +43,63 @@ function binaryByteLength(data) {
|
|
|
40
43
|
return -1;
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
46
|
+
* Octets imbriqués dans une valeur JSON : leur étiquette, jamais leur vidage.
|
|
47
|
+
*
|
|
48
|
+
* Lit la valeur BRUTE chez son parent (`this[key]`) : `JSON.stringify` appelle
|
|
49
|
+
* `toJSON` avant le replacer, et un `Buffer` — ce que rend un décodage depuis la
|
|
50
|
+
* trame de `ws` — y deviendrait `{"type":"Buffer","data":[…]}`.
|
|
51
|
+
*/
|
|
52
|
+
function binaryAsLabel(key, value) {
|
|
53
|
+
const raw = Reflect.get(this, key);
|
|
54
|
+
if (ArrayBuffer.isView(raw)) return binaryPayloadLabel(raw.byteLength);
|
|
55
|
+
if (raw instanceof ArrayBuffer) return binaryPayloadLabel(raw.byteLength);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/** Sérialisation d'une valeur pour une ligne de journal — jamais de levée. */
|
|
59
|
+
function stringifyForLog(value) {
|
|
60
|
+
let s;
|
|
61
|
+
try {
|
|
62
|
+
s = JSON.stringify(value, binaryAsLabel);
|
|
63
|
+
} catch {
|
|
64
|
+
return String(value);
|
|
65
|
+
}
|
|
66
|
+
return typeof s === "string" ? s : String(value);
|
|
67
|
+
}
|
|
68
|
+
/** Tronque à `cap` caractères, ellipse comprise au-delà. */
|
|
69
|
+
function bound(s, cap) {
|
|
70
|
+
return s.length > cap ? `${s.slice(0, cap)}…` : s;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
43
73
|
* Formate une charge utile WS en chaîne **bornée** et **sûre** pour le log :
|
|
44
74
|
* - `string` → tronquée à `cap` (+ ellipse) ;
|
|
45
|
-
* - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de
|
|
75
|
+
* - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de vidage) ;
|
|
76
|
+
* avec un `codec`, une trame d'un seul tenant est aussi décodée :
|
|
77
|
+
* `[binary N B] <protocole> <frame en JSON>`, octets imbriqués étiquetés ;
|
|
78
|
+
* illisible → `[binary N B] <protocole> illisible` ;
|
|
46
79
|
* - `null`/`undefined` → `""` ;
|
|
47
80
|
* - objet « JSON » → `JSON.stringify` compact tronqué (cycle, `bigint`, valeur
|
|
48
81
|
* non sérialisable → repli `String(...)`).
|
|
49
82
|
*
|
|
50
83
|
* @param data - charge utile (RECEIVE/SEND/BROADCAST).
|
|
51
84
|
* @param cap - longueur max avant troncature (défaut {@link WS_LOG_CONTENT_CAP}).
|
|
85
|
+
* @param codec - codec des trames binaires de la connexion, `null` = octets bruts.
|
|
86
|
+
* @returns la ligne à journaliser, jamais plus longue que `cap` + 1.
|
|
52
87
|
*/
|
|
53
|
-
function formatWsLogContent(data, cap = WS_LOG_CONTENT_CAP) {
|
|
54
|
-
if (typeof data === "string") return data
|
|
88
|
+
function formatWsLogContent(data, cap = WS_LOG_CONTENT_CAP, codec = null) {
|
|
89
|
+
if (typeof data === "string") return bound(data, cap);
|
|
55
90
|
if (data === null || data === void 0) return "";
|
|
56
91
|
const bytes = binaryByteLength(data);
|
|
57
|
-
if (bytes >= 0)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
if (bytes >= 0) {
|
|
93
|
+
const label = binaryPayloadLabel(bytes);
|
|
94
|
+
if (codec === null || !(data instanceof Uint8Array)) return label;
|
|
95
|
+
let frame;
|
|
96
|
+
try {
|
|
97
|
+
frame = codec.decode(data);
|
|
98
|
+
} catch {
|
|
99
|
+
return `${label} ${codec.protocol} illisible`;
|
|
100
|
+
}
|
|
101
|
+
return bound(`${label} ${codec.protocol} ${stringifyForLog(frame)}`, cap);
|
|
63
102
|
}
|
|
64
|
-
|
|
65
|
-
return s.length > cap ? `${s.slice(0, cap)}…` : s;
|
|
103
|
+
return bound(stringifyForLog(data), cap);
|
|
66
104
|
}
|
|
67
105
|
export { WS_LOG_CONTENT_CAP, binaryByteLength, formatWsLogContent };
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { extend } from "nodefony";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { parseCookie } from "cookie";
|
|
3
|
+
import { parseCookie, stringifySetCookie } from "cookie";
|
|
4
4
|
import MS from "ms";
|
|
5
5
|
// nodefony/src/cookies/cookie.ts
|
|
6
6
|
const encode = encodeURIComponent;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Décode une valeur de cookie sans jamais lever : une séquence `%` invalide
|
|
9
|
+
* rend la valeur brute (règle du paquet `cookie`). Un `decodeURIComponent`
|
|
10
|
+
* nu levait dans le constructeur du contexte, et la requête restait sans
|
|
11
|
+
* réponse (`Cookie: a=50%25`).
|
|
12
|
+
*/
|
|
13
|
+
function decode(value) {
|
|
14
|
+
if (!value.includes("%")) return value;
|
|
15
|
+
try {
|
|
16
|
+
return decodeURIComponent(value);
|
|
17
|
+
} catch {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Lecture de l'en-tête `Cookie` SANS décodage : la valeur est décodée une
|
|
23
|
+
* seule fois, par {@link Cookie.setValue}. Décoder aux deux endroits
|
|
24
|
+
* transformait `a%2541` en `aA` au lieu de `a%41`.
|
|
25
|
+
*/
|
|
26
|
+
const RAW_VALUES = { decode: (value) => value };
|
|
8
27
|
const cookieDefaultSettings = {
|
|
9
28
|
maxAge: 0,
|
|
10
29
|
path: "/",
|
|
@@ -15,9 +34,6 @@ const cookieDefaultSettings = {
|
|
|
15
34
|
signed: false,
|
|
16
35
|
secret: "!nodefony.secret!"
|
|
17
36
|
};
|
|
18
|
-
function parser(strToParse) {
|
|
19
|
-
return parseCookie(strToParse);
|
|
20
|
-
}
|
|
21
37
|
function cookiesParser(context) {
|
|
22
38
|
let cookies = null;
|
|
23
39
|
let co;
|
|
@@ -25,7 +41,7 @@ function cookiesParser(context) {
|
|
|
25
41
|
case "http":
|
|
26
42
|
if (context.request.request.headers.cookie) cookies = context.request.request.headers.cookie;
|
|
27
43
|
if (cookies) {
|
|
28
|
-
const obj =
|
|
44
|
+
const obj = parseCookie(cookies, RAW_VALUES);
|
|
29
45
|
for (const cookie in obj) {
|
|
30
46
|
co = new Cookie(cookie, obj[cookie]);
|
|
31
47
|
context.addRequestCookie(co);
|
|
@@ -36,7 +52,7 @@ function cookiesParser(context) {
|
|
|
36
52
|
case "websocket": {
|
|
37
53
|
const cookieHeader = context.request?.headers.cookie;
|
|
38
54
|
if (cookieHeader) {
|
|
39
|
-
const parsed =
|
|
55
|
+
const parsed = parseCookie(cookieHeader, RAW_VALUES);
|
|
40
56
|
for (const name in parsed) {
|
|
41
57
|
co = new Cookie(name, parsed[name]);
|
|
42
58
|
context.addRequestCookie(co);
|
|
@@ -165,14 +181,39 @@ var Cookie = class Cookie {
|
|
|
165
181
|
setPriority(val) {
|
|
166
182
|
return val;
|
|
167
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Durée de vie restante en SECONDES ENTIÈRES, `undefined` sous une seconde.
|
|
186
|
+
*
|
|
187
|
+
* RFC 6265 §4.1.1 : `max-age-av = "Max-Age=" non-zero-digit *DIGIT`. Un
|
|
188
|
+
* `Max-Age=3599.874` est ignoré par le navigateur, et le cookie devient un
|
|
189
|
+
* cookie de session. Arrondi INFÉRIEUR : le cookie ne survit jamais à son
|
|
190
|
+
* `Expires`.
|
|
191
|
+
*/
|
|
168
192
|
getMaxAge() {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
else this.maxAge = void 0;
|
|
173
|
-
} else this.maxAge = this.originalMaxAge;
|
|
193
|
+
const seconds = this.expires instanceof Date ? (this.expires.getTime() - Date.now()) / 1e3 : this.originalMaxAge;
|
|
194
|
+
const whole = seconds === void 0 ? 0 : Math.floor(seconds);
|
|
195
|
+
this.maxAge = whole >= 1 ? whole : void 0;
|
|
174
196
|
return this.maxAge;
|
|
175
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Vérifie la grammaire RFC 6265 §4.1.1 du nom, du `Path` et du `Domain`,
|
|
200
|
+
* au moment où le cookie est POSÉ sur la réponse.
|
|
201
|
+
*
|
|
202
|
+
* Sans elle, `path: "/; Domain=evil.example"` injectait un attribut, et un
|
|
203
|
+
* nom porteur de `;` partait tel quel. La grammaire est celle du paquet
|
|
204
|
+
* `cookie` : une seule implémentation.
|
|
205
|
+
*
|
|
206
|
+
* @throws {TypeError} nommant l'attribut hors grammaire.
|
|
207
|
+
*/
|
|
208
|
+
assertGrammar() {
|
|
209
|
+
const attributes = {
|
|
210
|
+
name: this.name,
|
|
211
|
+
value: ""
|
|
212
|
+
};
|
|
213
|
+
if (this.path !== void 0) attributes.path = this.path;
|
|
214
|
+
if (this.domain !== void 0) attributes.domain = this.domain;
|
|
215
|
+
stringifySetCookie(attributes);
|
|
216
|
+
}
|
|
176
217
|
toString() {
|
|
177
218
|
return `${this.name}=${encode(String(this.value))}`;
|
|
178
219
|
}
|
|
@@ -251,4 +292,4 @@ var Cookie = class Cookie {
|
|
|
251
292
|
return obj;
|
|
252
293
|
}
|
|
253
294
|
};
|
|
254
|
-
export { cookiesParser, Cookie as default
|
|
295
|
+
export { cookiesParser, Cookie as default };
|
|
@@ -37,12 +37,22 @@ var FrameProfile = class {
|
|
|
37
37
|
* invocation, il redevient exact, et le SQL se replace dans le waterfall.
|
|
38
38
|
*/
|
|
39
39
|
profilerQueries;
|
|
40
|
+
/**
|
|
41
|
+
* Ce profil sera-t-il gardé par le Profiler ? Faux quand seul le timing
|
|
42
|
+
* l'a ouvert (chemin d'outillage) : son id ne désigne alors aucun profil
|
|
43
|
+
* lisible, et ne doit pas être annoncé au client.
|
|
44
|
+
*/
|
|
45
|
+
get profiled() {
|
|
46
|
+
return this.profilerQueries !== null;
|
|
47
|
+
}
|
|
40
48
|
/** Resolver de la frame — porte route / controller / action. */
|
|
41
49
|
resolver = null;
|
|
42
50
|
/** Statut HTTP-équivalent de l'invocation (200, 403, 404…). */
|
|
43
51
|
response = null;
|
|
44
52
|
error = null;
|
|
45
53
|
timing;
|
|
54
|
+
clockStartMs;
|
|
55
|
+
durationMs = null;
|
|
46
56
|
constructor(init) {
|
|
47
57
|
this.requestId = init.requestId;
|
|
48
58
|
this.type = init.type;
|
|
@@ -54,9 +64,20 @@ var FrameProfile = class {
|
|
|
54
64
|
this.security = init.security;
|
|
55
65
|
this.securityTrace = init.securityTrace;
|
|
56
66
|
this.timing = init.timing;
|
|
67
|
+
this.clockStartMs = init.timing ? performance.now() : 0;
|
|
57
68
|
this.profilerQueries = init.queries ? [] : null;
|
|
58
69
|
}
|
|
59
70
|
/**
|
|
71
|
+
* Arrête l'horloge de la frame (au premier appel) et rend sa durée en ms ;
|
|
72
|
+
* `null` si le timing est éteint. Contrat de `IContext.stopClock`.
|
|
73
|
+
*
|
|
74
|
+
* @returns la durée de l'invocation, ou `null`
|
|
75
|
+
*/
|
|
76
|
+
stopClock() {
|
|
77
|
+
if (!this.timing) return null;
|
|
78
|
+
return this.durationMs ??= performance.now() - this.clockStartMs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
60
81
|
* Ouvre une phase. Contrairement au `Context`, l'index est un **scan arrière**
|
|
61
82
|
* (pas de `Map<nom, index>`) : une frame porte une poignée de phases, et une
|
|
62
83
|
* phase peut être ré-entrante (une action qui en déclenche une autre) — la
|
|
@@ -88,6 +109,7 @@ var FrameProfile = class {
|
|
|
88
109
|
* @param error - l'erreur qui a interrompu la frame, le cas échéant.
|
|
89
110
|
*/
|
|
90
111
|
finish(status, error) {
|
|
112
|
+
this.stopClock();
|
|
91
113
|
this.response = { statusCode: status };
|
|
92
114
|
if (error !== void 0 && error !== null) this.error = { message: thrownMessage(error) };
|
|
93
115
|
}
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { profileLane } from "./aggregate.js";
|
|
2
|
+
import { frameIndexOf } from "nodefony";
|
|
2
3
|
// nodefony/src/profiler/Profiler.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* Une phase encore OUVERTE à la collecte (action interrompue par le départ du
|
|
7
|
-
* client : flux SSE, outil MCP annulé) court jusqu'à la collecte — la compter
|
|
8
|
-
* pour zéro publiait 0,3 ms pour un appel coupé à 3 s.
|
|
9
|
-
*/
|
|
10
|
-
function totalDuration(phases) {
|
|
11
|
-
if (phases.length === 0) return null;
|
|
12
|
-
const first = phases.at(0);
|
|
13
|
-
if (first === void 0 || typeof first.startMs !== "number") return null;
|
|
14
|
-
let end = first.startMs;
|
|
15
|
-
let now = null;
|
|
16
|
-
for (const p of phases) {
|
|
17
|
-
const e = p.durationMs === void 0 ? now ??= performance.now() : p.startMs + p.durationMs;
|
|
18
|
-
if (e > end) end = e;
|
|
19
|
-
}
|
|
20
|
-
const d = end - first.startMs;
|
|
21
|
-
return d >= 0 ? Math.round(d * 1e3) / 1e3 : null;
|
|
4
|
+
/** Arrondit une durée à la microseconde (affichage du Suivi). */
|
|
5
|
+
function roundMicro(ms) {
|
|
6
|
+
return ms === null ? null : Math.round(ms * 1e3) / 1e3;
|
|
22
7
|
}
|
|
23
8
|
/** Capacité par défaut du ring buffer (assez pour une session de debug). */
|
|
24
9
|
const DEFAULT_CAP = 500;
|
|
@@ -46,6 +31,14 @@ var Profiler = class {
|
|
|
46
31
|
/** Dernier numéro d'ordre attribué (0 = aucun profil collecté). */
|
|
47
32
|
_seq = 0;
|
|
48
33
|
/**
|
|
34
|
+
* Démarrage du profileur, donc du processus (ms epoch, horloge du serveur) :
|
|
35
|
+
* aucune requête servie avant n'est connue. Rendu avec un profil absent, pour
|
|
36
|
+
* que le lecteur dise « le serveur a redémarré » plutôt que « évincé ».
|
|
37
|
+
*/
|
|
38
|
+
startedAt = Date.now();
|
|
39
|
+
/** Dernier vidage des anneaux (ms epoch), `null` s'il n'y en a jamais eu. */
|
|
40
|
+
_clearedAt = null;
|
|
41
|
+
/**
|
|
49
42
|
* @param cap - capacité de l'anneau de l'application.
|
|
50
43
|
* @param sideCap - capacité de chacun des anneaux de l'outillage et des
|
|
51
44
|
* assets relayés.
|
|
@@ -77,7 +70,7 @@ var Profiler = class {
|
|
|
77
70
|
url: ctx.url ?? "",
|
|
78
71
|
scheme: ctx.scheme ?? "",
|
|
79
72
|
status: ctx.response?.statusCode ?? (ctx.error ? 500 : null),
|
|
80
|
-
durationMs:
|
|
73
|
+
durationMs: roundMicro(ctx.stopClock()),
|
|
81
74
|
route: ctx.resolver?.route?.name ?? null,
|
|
82
75
|
controller: ctx.resolver?.controller?.name ?? null,
|
|
83
76
|
action: ctx.resolver?.actionName ?? null,
|
|
@@ -112,12 +105,17 @@ var Profiler = class {
|
|
|
112
105
|
}
|
|
113
106
|
/**
|
|
114
107
|
* Résumés des requêtes les plus récentes (récent → ancien), capés à `limit`.
|
|
108
|
+
*
|
|
109
|
+
* @param limit - nombre maximal de résumés
|
|
110
|
+
* @param connection - le requestId d'une connexion WebSocket : ses SEULES
|
|
111
|
+
* frames du pont (`<connection>.<n>`, règle `frameIndexOf` du cœur)
|
|
115
112
|
*/
|
|
116
|
-
recent(limit = 60) {
|
|
113
|
+
recent(limit = 60, connection) {
|
|
117
114
|
const out = [];
|
|
118
115
|
const entries = this.#collectAfter(0).reverse();
|
|
119
116
|
for (const e of entries) {
|
|
120
117
|
if (out.length >= limit) break;
|
|
118
|
+
if (connection && frameIndexOf(e.requestId, connection) === null) continue;
|
|
121
119
|
out.push({
|
|
122
120
|
requestId: e.requestId,
|
|
123
121
|
ts: e.ts,
|
|
@@ -161,12 +159,17 @@ var Profiler = class {
|
|
|
161
159
|
const { app, tooling, asset } = this._lanes;
|
|
162
160
|
return app.size + tooling.size + asset.size;
|
|
163
161
|
}
|
|
164
|
-
/** Vide les anneaux. */
|
|
162
|
+
/** Vide les anneaux, et retient l'instant du vidage ({@link clearedAt}). */
|
|
165
163
|
clear() {
|
|
166
164
|
const { app, tooling, asset } = this._lanes;
|
|
167
165
|
app.clear();
|
|
168
166
|
tooling.clear();
|
|
169
167
|
asset.clear();
|
|
168
|
+
this._clearedAt = Date.now();
|
|
169
|
+
}
|
|
170
|
+
/** Dernier vidage des anneaux (ms epoch, horloge du serveur), `null` si aucun. */
|
|
171
|
+
get clearedAt() {
|
|
172
|
+
return this._clearedAt;
|
|
170
173
|
}
|
|
171
174
|
/**
|
|
172
175
|
* Profils de numéro supérieur à `from`, tous anneaux confondus, du plus
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MODULE_PROXY_PREFIXES } from "../proxy/rules.js";
|
|
2
|
+
import { isConsolePath } from "nodefony";
|
|
2
3
|
// nodefony/src/profiler/aggregate.ts
|
|
3
4
|
/**
|
|
4
5
|
* Agrégation des profils de requêtes — règle PURE, sans horloge ni I/O.
|
|
@@ -22,20 +23,28 @@ const ROUTE_SAMPLE_CAP = 256;
|
|
|
22
23
|
const SQL_SHAPE_CAP = 200;
|
|
23
24
|
/** Exemples d'erreurs retenus (statut ≥ 500 ou exception). */
|
|
24
25
|
const ERROR_SAMPLE_CAP = 10;
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Préfixe de l'espace réservé au framework.
|
|
28
|
+
*
|
|
29
|
+
* @deprecated Ne dit pas ce qui est de l'outillage : une connexion
|
|
30
|
+
* d'utilisateur (`/nodefony/security/api/auth/…`) ou une route d'application
|
|
31
|
+
* (`/nodefony/test/index`) passent sous ce préfixe. La règle est
|
|
32
|
+
* `isConsolePath` (`nodefony`), que `profileLane` appelle.
|
|
33
|
+
*/
|
|
26
34
|
const TOOLING_URL_PREFIX = "/nodefony/";
|
|
27
35
|
/**
|
|
28
|
-
* Range un profil par provenance, d'après son URL.
|
|
29
|
-
*
|
|
36
|
+
* Range un profil par provenance, d'après son URL. L'outillage est reconnu par
|
|
37
|
+
* la règle du cœur, `isConsolePath` — la même que les Journaux, la barre de
|
|
38
|
+
* débogage et le Studio : une connexion d'utilisateur reste de l'application.
|
|
39
|
+
* Les anneaux du profileur et le filtre de l'agrégation la lisent tous deux.
|
|
30
40
|
*
|
|
31
41
|
* @param url - URL du profil, absolue ou chemin.
|
|
32
42
|
* @returns la provenance du profil.
|
|
33
43
|
*/
|
|
34
44
|
function profileLane(url) {
|
|
35
45
|
const path = urlPath(url);
|
|
36
|
-
if (path.startsWith("/nodefony/")) return "tooling";
|
|
37
46
|
for (const prefix of MODULE_PROXY_PREFIXES) if (path.startsWith(prefix)) return "asset";
|
|
38
|
-
return "app";
|
|
47
|
+
return isConsolePath(path) ? "tooling" : "app";
|
|
39
48
|
}
|
|
40
49
|
/**
|
|
41
50
|
* Le profil vient-il de l'outillage (routes `/nodefony/…` ou assets relayés)
|
|
@@ -46,6 +55,17 @@ function profileLane(url) {
|
|
|
46
55
|
function isToolingUrl(url) {
|
|
47
56
|
return profileLane(url) !== "app";
|
|
48
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Une requête de cette URL se profile-t-elle ? L'application toujours ;
|
|
60
|
+
* l'outillage et les assets relayés seulement sur demande (`timing.tooling`)
|
|
61
|
+
* — on ne mesure pas la façon dont on mesure.
|
|
62
|
+
*
|
|
63
|
+
* @param url - URL ou chemin de la requête (ou de la frame du pont)
|
|
64
|
+
* @param tooling - profiler aussi l'outillage
|
|
65
|
+
*/
|
|
66
|
+
function isProfiledUrl(url, tooling) {
|
|
67
|
+
return tooling || profileLane(url) === "app";
|
|
68
|
+
}
|
|
49
69
|
/** Groupe des requêtes qu'aucune route n'a servies (404, statique, sondes). */
|
|
50
70
|
const NO_ROUTE = "(aucune route)";
|
|
51
71
|
/** Pseudo-phase : le temps de l'action hors phases imbriquées et hors SQL. */
|
|
@@ -419,4 +439,4 @@ function aggregateProfiles(entries, options = {}) {
|
|
|
419
439
|
for (const entry of entries) aggregator.add(entry);
|
|
420
440
|
return aggregator.result();
|
|
421
441
|
}
|
|
422
|
-
export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isToolingUrl, profileLane, sqlShape, urlPath };
|
|
442
|
+
export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isProfiledUrl, isToolingUrl, profileLane, sqlShape, urlPath };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUnsupportedTransferCoding } from "./rules.js";
|
|
1
2
|
import http from "node:http";
|
|
2
3
|
import https from "node:https";
|
|
3
4
|
// nodefony/src/proxy/forward.ts
|
|
@@ -83,6 +84,71 @@ function viaLoops(via, pseudonym) {
|
|
|
83
84
|
for (const hop of hops) if (hop.trim().split(/\s+/)[1] === pseudonym) return true;
|
|
84
85
|
return false;
|
|
85
86
|
}
|
|
87
|
+
/** `1*DIGIT` (RFC 9110 §7.6.2) — compilé une fois. */
|
|
88
|
+
const DIGITS = /^[0-9]+$/;
|
|
89
|
+
/**
|
|
90
|
+
* `Max-Forwards` reçu, tel qu'un intermédiaire doit le lire (RFC 9110 §7.6.2) :
|
|
91
|
+
* sur `OPTIONS` et `TRACE` seulement — la même section permet de l'ignorer
|
|
92
|
+
* pour toute autre méthode, et il part alors tel quel.
|
|
93
|
+
*
|
|
94
|
+
* @param method - méthode de la requête
|
|
95
|
+
* @param value - en-tête `Max-Forwards` reçu
|
|
96
|
+
* @returns `null` s'il n'y a rien à faire (autre méthode, en-tête absent) ;
|
|
97
|
+
* `-1` si la valeur n'est pas `1*DIGIT` — elle ne se décrémente pas, l'en-tête
|
|
98
|
+
* n'est donc pas transmis ; sinon la valeur reçue (`0` : le proxy répond
|
|
99
|
+
* lui-même, sans joindre l'amont).
|
|
100
|
+
*/
|
|
101
|
+
function receivedMaxForwards(method, value) {
|
|
102
|
+
if (value === void 0 || method !== "OPTIONS" && method !== "TRACE") return null;
|
|
103
|
+
const raw = (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
|
104
|
+
return DIGITS.test(raw) ? Number(raw) : -1;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* En-têtes qu'une réponse à `TRACE` ne renvoie pas : ils portent des secrets
|
|
108
|
+
* (RFC 9110 §9.3.8 : le destinataire final DEVRAIT exclure les champs
|
|
109
|
+
* susceptibles de contenir des données sensibles).
|
|
110
|
+
*/
|
|
111
|
+
const TRACE_SENSITIVE = /^(?:authorization|proxy-authorization|cookie)$|token|secret|session|api-?key|csrf/i;
|
|
112
|
+
/**
|
|
113
|
+
* Corps `message/http` d'une réponse à `TRACE` : la requête reçue, en-têtes
|
|
114
|
+
* sensibles et pseudo-en-têtes HTTP/2 retirés (RFC 9110 §9.3.8).
|
|
115
|
+
*/
|
|
116
|
+
function traceEcho(req) {
|
|
117
|
+
let echo = `TRACE ${req.url ?? "/"} HTTP/${req.httpVersion}\r\n`;
|
|
118
|
+
const authority = req.headers[":authority"];
|
|
119
|
+
if (req.headers.host === void 0 && typeof authority === "string") echo += `host: ${authority}\r\n`;
|
|
120
|
+
for (const name of Object.keys(req.headers)) {
|
|
121
|
+
if (name.charCodeAt(0) === 58 || TRACE_SENSITIVE.test(name)) continue;
|
|
122
|
+
echo += `${name}: ${flat(req.headers[name])}\r\n`;
|
|
123
|
+
}
|
|
124
|
+
return `${echo}\r\n`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Réponse du proxy en DESTINATAIRE FINAL, quand `Max-Forwards` est épuisé
|
|
128
|
+
* (RFC 9110 §7.6.2) : `TRACE` renvoie la requête reçue (§9.3.8), `OPTIONS`
|
|
129
|
+
* rend 200 avec `Allow` si le montage restreint ses méthodes, et
|
|
130
|
+
* `Content-Length: 0` (§9.3.7). L'amont n'est jamais joint.
|
|
131
|
+
*/
|
|
132
|
+
function answerAsFinalRecipient(req, out, mount) {
|
|
133
|
+
req.resume();
|
|
134
|
+
if (req.method === "TRACE") {
|
|
135
|
+
const body = traceEcho(req);
|
|
136
|
+
out.writeHead(200, {
|
|
137
|
+
"content-type": "message/http",
|
|
138
|
+
"content-length": Buffer.byteLength(body),
|
|
139
|
+
"cache-control": "no-store"
|
|
140
|
+
});
|
|
141
|
+
out.end(body);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const headers = {
|
|
145
|
+
"content-length": 0,
|
|
146
|
+
"cache-control": "no-store"
|
|
147
|
+
};
|
|
148
|
+
if (mount.methods !== void 0) headers.allow = mount.methods.join(", ");
|
|
149
|
+
out.writeHead(200, headers);
|
|
150
|
+
out.end();
|
|
151
|
+
}
|
|
86
152
|
/** Entrée `Via` de ce saut : `<version> <pseudonyme>` (RFC 9110 §7.6.3). */
|
|
87
153
|
function viaEntry(httpVersion, pseudonym) {
|
|
88
154
|
return `${httpVersion.startsWith("2") ? "2" : httpVersion || "1.1"} ${pseudonym}`;
|
|
@@ -98,10 +164,7 @@ function viaEntry(httpVersion, pseudonym) {
|
|
|
98
164
|
*/
|
|
99
165
|
function bodyFramingOf(req) {
|
|
100
166
|
const coding = req.headers["transfer-encoding"];
|
|
101
|
-
if (coding !== void 0)
|
|
102
|
-
if (coding === "chunked") return "chunked";
|
|
103
|
-
return coding.trim().toLowerCase() === "chunked" ? "chunked" : "unsupported";
|
|
104
|
-
}
|
|
167
|
+
if (coding !== void 0) return isUnsupportedTransferCoding(coding) ? "unsupported" : "chunked";
|
|
105
168
|
if (req.headers["content-length"] !== void 0) return "length";
|
|
106
169
|
const stream = req.stream;
|
|
107
170
|
if (stream !== void 0 && !stream.endAfterHeaders) return "chunked";
|
|
@@ -264,8 +327,9 @@ function armConnectTimeout(upstream, ms, onTimeout) {
|
|
|
264
327
|
* Relaie une requête HTTP vers l'amont et renvoie sa réponse au client.
|
|
265
328
|
*
|
|
266
329
|
* Le corps est transmis avec un cadrage cohérent ({@link bodyFramingOf}). Un
|
|
267
|
-
* message qui porte déjà notre `Via` rend 508 sans contacter l'amont.
|
|
268
|
-
*
|
|
330
|
+
* message qui porte déjà notre `Via` rend 508 sans contacter l'amont. Sur
|
|
331
|
+
* `OPTIONS` et `TRACE`, `Max-Forwards` est décrémenté ; à `0`, le proxy répond
|
|
332
|
+
* lui-même en destinataire final (RFC 9110 §7.6.2). Un client parti coupe l'échange amont ; un amont injoignable rend 502 ; une
|
|
269
333
|
* connexion non établie à temps ou un amont muet rendent 504 — à condition
|
|
270
334
|
* que rien n'ait encore été envoyé, sinon la réponse est interrompue (un
|
|
271
335
|
* statut ne se rattrape pas). Un amont coupé en plein corps interrompt la
|
|
@@ -307,10 +371,21 @@ function forwardRequest(req, res, mount, target, from, relay) {
|
|
|
307
371
|
fail(501, NOT_IMPLEMENTED);
|
|
308
372
|
return;
|
|
309
373
|
}
|
|
374
|
+
const method = req.method ?? "GET";
|
|
375
|
+
const hops = receivedMaxForwards(method, req.headers["max-forwards"]);
|
|
376
|
+
if (hops === 0) {
|
|
377
|
+
answerAsFinalRecipient(req, out, mount);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
310
380
|
const options = requestOptions(target, mount, relay);
|
|
311
|
-
options.method =
|
|
381
|
+
options.method = method;
|
|
312
382
|
options.path = upstreamPath(req.url ?? "/", mount);
|
|
313
|
-
|
|
383
|
+
const headers = requestHeadersFor(req.headers, mount, target, from, relay.pseudonym, framing);
|
|
384
|
+
if (hops !== null) {
|
|
385
|
+
if (hops > 0) headers["max-forwards"] = String(Math.min(hops - 1, 10));
|
|
386
|
+
else delete headers["max-forwards"];
|
|
387
|
+
}
|
|
388
|
+
options.headers = headers;
|
|
314
389
|
if (mount.timeoutMs !== void 0) options.timeout = mount.timeoutMs;
|
|
315
390
|
const request = requestFn(target)(options);
|
|
316
391
|
upstream = request;
|
|
@@ -414,6 +489,31 @@ function refuseUpgrade(socket, status, reason, headers) {
|
|
|
414
489
|
}
|
|
415
490
|
socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
416
491
|
}
|
|
492
|
+
/** En-têtes du refus d'une version WebSocket inconnue — figés, partagés. */
|
|
493
|
+
const WS_VERSION_REFUSAL_HEADERS = Object.freeze({
|
|
494
|
+
Upgrade: "websocket",
|
|
495
|
+
"Sec-WebSocket-Version": "13"
|
|
496
|
+
});
|
|
497
|
+
/**
|
|
498
|
+
* Refuse un handshake WebSocket non conforme — la réponse qui convient à la
|
|
499
|
+
* raison rendue par `websocketHandshakeProblem`, seule implémentation pour le
|
|
500
|
+
* serveur WebSocket ET le proxy inverse.
|
|
501
|
+
*
|
|
502
|
+
* Une version inconnue reçoit `426` avec `Sec-WebSocket-Version: 13` : le
|
|
503
|
+
* client apprend vers quelle version se rabattre (RFC 6455 §4.2.2 point 6,
|
|
504
|
+
* §4.4), et le 426 porte l'`Upgrade` qu'exige RFC 9110 §15.5.22. Tout autre
|
|
505
|
+
* défaut de forme reçoit `400`.
|
|
506
|
+
*
|
|
507
|
+
* @param socket - socket du client, encore en attente de sa réponse
|
|
508
|
+
* @param problem - la raison rendue par `websocketHandshakeProblem`
|
|
509
|
+
*/
|
|
510
|
+
function refuseMalformedHandshake(socket, problem) {
|
|
511
|
+
if (problem === "Sec-WebSocket-Version ≠ 13") {
|
|
512
|
+
refuseUpgrade(socket, 426, "Upgrade Required", WS_VERSION_REFUSAL_HEADERS);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
refuseUpgrade(socket, 400, "Bad Request");
|
|
516
|
+
}
|
|
417
517
|
/** En-têtes d'une réponse d'amont REFUSANT l'upgrade, à ne pas recopier tels quels. */
|
|
418
518
|
const REFUSAL_SKIP = /* @__PURE__ */ new Set([
|
|
419
519
|
"transfer-encoding",
|
|
@@ -497,4 +597,4 @@ function forwardUpgrade(req, socket, head, mount, target, from, relay) {
|
|
|
497
597
|
});
|
|
498
598
|
upstream.end();
|
|
499
599
|
}
|
|
500
|
-
export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
|
|
600
|
+
export { MAX_VIA_HOPS, bodyFramingOf, forwardRequest, forwardUpgrade, rawHead, receivedMaxForwards, refuseMalformedHandshake, refuseUpgrade, requestHeadersFor, responseHeadersFor, rewriteLocation, upstreamPath, viaLoops };
|
|
@@ -152,6 +152,12 @@ defaults
|
|
|
152
152
|
# \`timeout server\` ne s'applique plus au tunnel. Sans cette ligne, la valeur
|
|
153
153
|
# implicite coupe des sockets que le heartbeat gardait pourtant vivantes.
|
|
154
154
|
timeout tunnel ${idleSeconds}s
|
|
155
|
+
# Forwarded (RFC 7239) posé par haproxy lui-même (>= 2.8) : il met entre
|
|
156
|
+
# guillemets ce qui n'est pas un jeton — une IPv6 (\`for="[2001:db8::1]"\`),
|
|
157
|
+
# un \`host\` qui porte un port. Un \`set-header\` écrit à la main laissait
|
|
158
|
+
# \`for=2001:db8::1\`, valeur invalide (§6). \`proto\` suit \`ssl_fc\` : le
|
|
159
|
+
# scheme vu par le CLIENT. Vaut pour tous les backends.
|
|
160
|
+
option forwarded proto host for
|
|
155
161
|
|
|
156
162
|
frontend fe_nodefony
|
|
157
163
|
bind *:${intro.listen}
|
|
@@ -173,8 +179,6 @@ frontend fe_nodefony
|
|
|
173
179
|
http-request set-header X-Forwarded-Proto http unless { ssl_fc }
|
|
174
180
|
http-request set-header X-Forwarded-Host %[req.hdr(host)]
|
|
175
181
|
http-request set-header X-Real-IP %[src]
|
|
176
|
-
http-request set-header Forwarded "for=%[src];proto=https;host=%[req.hdr(host)]" if { ssl_fc }
|
|
177
|
-
http-request set-header Forwarded "for=%[src];proto=http;host=%[req.hdr(host)]" unless { ssl_fc }
|
|
178
182
|
|
|
179
183
|
${h2cPort === null ? " default_backend be_nodefony\n" : " # h2c (servers.h2c) : HTTP/2 en clair vers le backend, plusieurs flux par\n # connexion. WebSocket reste en HTTP/1.1 sur le port HTTP : le port h2c\n # n'annonce pas le CONNECT étendu (RFC 8441).\n use_backend be_nodefony if { hdr(upgrade) -i websocket }\n default_backend be_nodefony_h2c\n"}
|
|
180
184
|
backend be_nodefony
|