@nodefony/http 10.0.0-beta.2 → 10.0.0-beta.3

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.
Files changed (76) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +11 -5
  5. package/dist/nodefony/config/config.js +44 -4
  6. package/dist/nodefony/config/defineModuleConfig.js +1 -1
  7. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  8. package/dist/nodefony/service/HttpAdminApi.js +0 -1
  9. package/dist/nodefony/service/audit-logger.js +2 -3
  10. package/dist/nodefony/service/certificates.js +140 -15
  11. package/dist/nodefony/service/error-renderer.js +1 -1
  12. package/dist/nodefony/service/http-kernel.js +221 -47
  13. package/dist/nodefony/service/reverse-proxy.js +336 -0
  14. package/dist/nodefony/service/servers/server-http.js +3 -3
  15. package/dist/nodefony/service/servers/server-https.js +3 -3
  16. package/dist/nodefony/service/servers/server-static.js +4 -7
  17. package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
  18. package/dist/nodefony/service/servers/server-websocket.js +12 -4
  19. package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
  20. package/dist/nodefony/service/sessions/sessions-service.js +30 -9
  21. package/dist/nodefony/service/upload/upload-service.js +3 -3
  22. package/dist/nodefony/src/context/Context.js +28 -1
  23. package/dist/nodefony/src/context/domainMatcher.js +20 -2
  24. package/dist/nodefony/src/context/forwarded.js +2 -2
  25. package/dist/nodefony/src/context/http/HttpContext.js +91 -40
  26. package/dist/nodefony/src/context/http/Request.js +1 -9
  27. package/dist/nodefony/src/context/http/Response.js +16 -1
  28. package/dist/nodefony/src/context/http/SseStream.js +350 -0
  29. package/dist/nodefony/src/context/responseEnded.js +27 -0
  30. package/dist/nodefony/src/context/websocket/Response.js +9 -0
  31. package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
  32. package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
  33. package/dist/nodefony/src/profiler/Profiler.js +6 -10
  34. package/dist/nodefony/src/proxy/forward.js +476 -0
  35. package/dist/nodefony/src/proxy/rules.js +182 -0
  36. package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
  37. package/dist/nodefony/src/session/session.js +28 -12
  38. package/dist/types/index.d.ts +5 -0
  39. package/dist/types/nodefony/config/config.d.ts +16 -0
  40. package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
  41. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  42. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  43. package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
  44. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  45. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  46. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  47. package/dist/types/nodefony/service/certificates.d.ts +96 -5
  48. package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
  49. package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
  50. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  51. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
  52. package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
  53. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  54. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  55. package/dist/types/nodefony/src/context/Context.d.ts +22 -2
  56. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  57. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
  58. package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
  59. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  60. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  61. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  62. package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
  63. package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
  64. package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
  65. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
  66. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  67. package/docs/cookies.md +13 -13
  68. package/docs/index.md +5 -1
  69. package/docs/observabilite.md +15 -15
  70. package/docs/rate-limit.md +30 -30
  71. package/docs/reverse-proxy.md +522 -0
  72. package/docs/servers.md +94 -60
  73. package/docs/session.md +61 -61
  74. package/docs/sse.md +283 -0
  75. package/docs/upload.md +23 -23
  76. package/package.json +8 -8
@@ -0,0 +1,156 @@
1
+ import http from "node:http";
2
+ import type { Duplex } from "node:stream";
3
+ import { Service, Module } from "nodefony";
4
+ import type { IProxyMount, IProxyMountOptions } from "../interfaces/IReverseProxy.js";
5
+ import { type ProxiedRequest, type ProxiedResponse } from "../src/proxy/forward.js";
6
+ /** Un montage tel que le service le garde : la dernière cible analysée y est mise en cache. */
7
+ interface IMountRecord extends IProxyMount {
8
+ /** D'où vient le montage : la configuration de l'application, ou un module. */
9
+ readonly source: "config" | "module";
10
+ /** Dernière origine rendue par `target`, et son analyse. */
11
+ lastOrigin: string | undefined;
12
+ lastUrl: URL | undefined;
13
+ /** Pool de connexions vers l'amont, créé au premier relais (scheme de la cible). */
14
+ agent: http.Agent | null;
15
+ /** Tunnels WebSocket ouverts vers l'amont — bornés par `maxSockets`, hors pool. */
16
+ tunnels: number;
17
+ }
18
+ /**
19
+ * Proxy inverse de Nodefony : relaie un préfixe d'URL vers un autre serveur
20
+ * HTTP — requêtes ET, sur demande, upgrades WebSocket — sur la MÊME origine
21
+ * que la page. Le client ne voit qu'un serveur, un certificat ; l'amont peut
22
+ * rester sur la boucle locale.
23
+ *
24
+ * Deux sources de montages, mêmes règles (`src/proxy/rules.ts`) : la section
25
+ * `proxy.mounts` de la configuration, lue à la construction, et
26
+ * {@link ReverseProxy.mount}, appelé par un module (`@nodefony/frontend` y
27
+ * relaie `/_vite/<famille>/` vers ses serveurs Vite de développement). Un
28
+ * module se résout ce service par NOM (`container.get("reverse-proxy")`) :
29
+ * il n'a pas à importer `@nodefony/http`.
30
+ *
31
+ * Consulté AVANT le routage : une route attrape-tout de l'application ne peut
32
+ * pas avaler un préfixe relayé. Sans montage, le pipeline ne paie qu'une
33
+ * lecture de champ (`mounts === null`) — c'est le cas de toute application
34
+ * qui ne s'en sert pas.
35
+ *
36
+ * Règles communes à tout montage : en-têtes de connexion jamais transmis
37
+ * (RFC 9110 §7.6.1), `X-Forwarded-For/-Proto/-Host` et `Via` posés par le
38
+ * proxy, Host hors `trustedHosts` jamais relayé (même barrière, même 421 que
39
+ * toute requête), amont injoignable → 502, amont muet → 504, boucle → 508.
40
+ */
41
+ declare class ReverseProxy extends Service {
42
+ #private;
43
+ module: Module;
44
+ /**
45
+ * Montages actifs, du préfixe le PLUS LONG au plus court : le premier qui
46
+ * couvre une URL est le plus spécifique. `null` tant qu'il n'y en a aucun.
47
+ */
48
+ mounts: IMountRecord[] | null;
49
+ /** Délai d'inactivité appliqué aux montages qui n'en fixent pas (`proxy.timeoutMs`). */
50
+ readonly defaultTimeoutMs: number;
51
+ /** Délai d'établissement de la connexion amont (`proxy.connectTimeoutMs`). */
52
+ readonly connectTimeoutMs: number;
53
+ /** Connexions simultanées par amont (`proxy.maxSockets`) : pool ET tunnels WebSocket. */
54
+ readonly maxSockets: number;
55
+ /**
56
+ * Nom de CE processus dans `Via` (RFC 9110 §7.6.3) : tiré au hasard au
57
+ * démarrage, il ne dit rien de la machine et distingue deux instances —
58
+ * un message qui le porte déjà est une boucle (508).
59
+ */
60
+ readonly pseudonym: string;
61
+ constructor(module: Module);
62
+ /**
63
+ * Relaie un préfixe d'URL vers un amont. Idempotent pour un même module :
64
+ * un même préfixe est remplacé. Un module qui recouvre un préfixe de la
65
+ * CONFIGURATION le remplace aussi, mais le dit — l'auteur de l'application
66
+ * doit pouvoir lire pourquoi son montage ne sert plus.
67
+ *
68
+ * @param prefix - préfixe d'URL (normalisé : `/` en tête et en fin)
69
+ * @param options - cible et réglages du montage
70
+ * @throws Error qui nomme chaque refus (préfixe `/` ou réservé, cible qui
71
+ * n'est pas une origine http(s) nue, `secure: false` hors boucle locale…)
72
+ */
73
+ mount(prefix: string, options: IProxyMountOptions): void;
74
+ /**
75
+ * Retire un montage. Le dernier retiré rend `mounts` à `null` : le pipeline
76
+ * cesse alors de le consulter.
77
+ *
78
+ * @param prefix - préfixe tel que monté (normalisé de la même façon)
79
+ */
80
+ unmount(prefix: string): void;
81
+ /**
82
+ * Ferme tous les pools (arrêt du noyau). Les montages restent déclarés ; un
83
+ * relais encore en vol part ensuite sur une connexion à usage unique.
84
+ */
85
+ closeAll(): void;
86
+ /**
87
+ * Montage le plus spécifique qui couvre une URL, s'il y en a un.
88
+ *
89
+ * @param url - cible brute de la requête (chemin + requête)
90
+ * @returns le montage au préfixe le plus long qui couvre l'URL
91
+ */
92
+ match(url: string | undefined): IProxyMount | undefined;
93
+ /**
94
+ * Origine courante d'un montage, analysée — mise en cache tant que la cible
95
+ * rend la même chaîne.
96
+ *
97
+ * @returns l'URL de l'amont, ou `undefined` si rien n'est relayable
98
+ */
99
+ private targetOf;
100
+ /**
101
+ * Pool de connexions d'un montage, créé au premier relais et recréé si la
102
+ * cible change de scheme.
103
+ *
104
+ * Un `Agent` DÉDIÉ, jamais l'agent global : sous `NODE_USE_ENV_PROXY`, cas
105
+ * courant d'un poste d'entreprise, l'agent global envoie tout vers
106
+ * `HTTP_PROXY` — un amont sur la boucle locale serait demandé au proxy de
107
+ * l'entreprise. Un agent construit ici n'a pas de `proxyEnv` : il va droit
108
+ * à la cible. Keep-alive borné (`maxSockets`), `lifo` : le socket le plus
109
+ * récent, le moins susceptible d'avoir été fermé par l'amont.
110
+ */
111
+ private agentFor;
112
+ /** Réglages de relais d'un montage. */
113
+ private relayFor;
114
+ /** Règles du noyau HTTP (résolues par nom), `undefined` hors serveur. */
115
+ private rules;
116
+ /** Ce que le proxy annonce à l'amont sur le client. */
117
+ private fromOf;
118
+ /**
119
+ * Relaie une requête HTTP si un montage la couvre — méthode comprise. Cible
120
+ * pas encore connue → `503` + `Retry-After`, sans aller jusqu'au routage.
121
+ *
122
+ * L'appelant a déjà tranché la barrière d'hôte : c'est le pipeline HTTP, qui
123
+ * la calcule pour toute requête (`Context.validDomain`). Un chemin ambigu
124
+ * sous un préfixe monté rend 400, sans contacter l'amont.
125
+ *
126
+ * ⚠️ Un préfixe monté est servi AVANT le pare-feu, la CSRF et les en-têtes
127
+ * de sécurité applicatifs : c'est une surface publique pour qui atteint
128
+ * Nodefony. L'amont porte sa propre politique d'accès.
129
+ *
130
+ * @param req - requête du client
131
+ * @param res - réponse au client
132
+ * @param scheme - scheme par lequel le client est arrivé
133
+ * @returns la promesse de fin d'échange, ou `undefined` si la requête n'est
134
+ * pas relayée (hors préfixe ou méthode non relayée : elle suit alors son
135
+ * chemin normal)
136
+ */
137
+ forward(req: ProxiedRequest, res: ProxiedResponse, scheme: string): Promise<void> | undefined;
138
+ /**
139
+ * Relaie un upgrade WebSocket si un montage `websocket` couvre son URL.
140
+ * Appelé par le répartiteur d'`upgrade` des serveurs WebSocket, AVANT le
141
+ * serveur WebSocket de Nodefony.
142
+ *
143
+ * Contrôles, dans l'ordre, chacun avec la réponse que rendrait le serveur
144
+ * WebSocket de Nodefony : cible pas encore connue → 503 ; `Host` hors `trustedHosts` (si `domainCheck`) →
145
+ * 421 ; chemin ambigu ou handshake non conforme (RFC 6455 §4.2.1 — seul
146
+ * `Upgrade: websocket` est relayé, jamais un tunnel `h2c`) → 400 ;
147
+ * `Origin` refusée par la MÊME règle que le serveur WebSocket (anti-CSWSH,
148
+ * RFC 6455 §10.2) → 403 ; `maxSockets` tunnels déjà ouverts vers l'amont →
149
+ * 503 ; quotas par IP du noyau (débit de handshakes, connexions
150
+ * simultanées) → 429. Le socket est alors consommé.
151
+ *
152
+ * @returns `true` si le proxy a pris le socket en charge
153
+ */
154
+ handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): boolean;
155
+ }
156
+ export default ReverseProxy;
@@ -7,6 +7,7 @@ import { AddressInfo } from "node:net";
7
7
  import type { IncomingMessage } from "node:http";
8
8
  import httpsServers from "./server-https.js";
9
9
  declare class WebsocketSecure extends Service {
10
+ #private;
10
11
  private readonly httpKernel;
11
12
  options: IHttpConfig["websocketSecure"] & DefaultOptionsService;
12
13
  module: Module;
@@ -7,6 +7,7 @@ import { AddressInfo } from "node:net";
7
7
  import type { IncomingMessage } from "node:http";
8
8
  import httpServer from "./server-http.js";
9
9
  declare class Websocket extends Service {
10
+ #private;
10
11
  private readonly httpKernel;
11
12
  options: IHttpConfig["websocket"] & DefaultOptionsService;
12
13
  module: Module;
@@ -13,6 +13,18 @@ export interface IWsHeartbeatOptions {
13
13
  /** Délai (ms) accordé pour recevoir le `pong` avant de couper la socket. */
14
14
  keepaliveGracePeriod?: number;
15
15
  }
16
+ /**
17
+ * Ce que le tick lit et écrit d'une connexion — la socket `ws` en production.
18
+ */
19
+ export interface IHeartbeatClient {
20
+ readonly readyState: number;
21
+ ping(): void;
22
+ terminate(): void;
23
+ /** Dernier signe de vie (date du dernier `pong`, ou de la connexion). */
24
+ _nfLastPong?: number;
25
+ /** Date du dernier `ping` émis ; `0` = aucun ping en attente de réponse. */
26
+ _nfPingedAt?: number;
27
+ }
16
28
  /**
17
29
  * Arme le suivi keep-alive d'UNE connexion : initialise l'horodatage de vie et
18
30
  * attache l'unique listener `pong` qui le rafraîchit.
@@ -24,6 +36,20 @@ export interface IWsHeartbeatOptions {
24
36
  * @param ws - la socket fraîchement connectée
25
37
  */
26
38
  export declare const trackPong: (ws: Ws) => void;
39
+ /**
40
+ * Un tick du keep-alive : pingue les connexions muettes depuis `interval`, et
41
+ * coupe celles dont le ping attend sa réponse depuis plus de `grace`.
42
+ *
43
+ * Fonction PURE du temps : l'horloge est passée en argument, si bien que la
44
+ * règle se prouve pas à pas, sans minuteur réel — un banc qui la prouverait à
45
+ * l'horloge murale confondrait une boucle d'événements chargée avec un zombie.
46
+ *
47
+ * @param clients - les connexions du serveur
48
+ * @param now - l'instant du tick (`performance.now()` en production)
49
+ * @param interval - délai sans signe de vie avant un ping, ms
50
+ * @param grace - délai accordé au pong avant de couper, ms
51
+ */
52
+ export declare const heartbeatTick: (clients: Iterable<IHeartbeatClient>, now: number, interval: number, grace: number) => void;
27
53
  /**
28
54
  * Démarre le heartbeat keep-alive du serveur : détecte les connexions zombies
29
55
  * (half-open — TCP encore ouvert mais le pair a disparu sans frame Close) et les
@@ -1,6 +1,6 @@
1
1
  import { Service, Module } from "nodefony";
2
2
  import type { IPage } from "nodefony";
3
- import type { ISessionStorage, ISessionSummary, ISessionRecord, ISessionListQuery } from "../../interfaces/ISession.js";
3
+ import type { ISessionStorage, ISerializedSession, ISessionSummary, ISessionRecord, ISessionListFilter, ISessionListQuery } from "../../interfaces/ISession.js";
4
4
  import HttpKernel, { ContextType } from "../http-kernel.js";
5
5
  import Session, { OptionsSessionType } from "../../src/session/session.js";
6
6
  import type { IHttpConfig } from "../../config/config.js";
@@ -191,6 +191,24 @@ declare class SessionsService extends Service {
191
191
  * est sûre. Idempotent — `false` si aucun `ref` ne correspond.
192
192
  */
193
193
  destroyByRef(ref: string, actor?: string | null): Promise<boolean>;
194
+ /**
195
+ * Détruit TOUTES les sessions dont le contenu satisfait `match` — la primitive
196
+ * des révocations groupées (« déconnecter partout », déconnexion demandée par
197
+ * un fournisseur d'identité). Ni journal ni audit : l'appelant, qui connaît le
198
+ * motif, les émet.
199
+ *
200
+ * @remarks Quand la méthode rend la main, il ne reste AUCUNE session qui
201
+ * satisfait `match` (au plus `MAX_LOGOUT_PASSES` passages). Une révocation qui
202
+ * en laisse une seule n'est pas une imprécision, c'est une faille.
203
+ *
204
+ * @param filter - restriction poussée au store (`user` : WHERE indexable) ;
205
+ * `undefined` parcourt tout le parc.
206
+ * @param match - prédicat sur la session sérialisée — il doit RE-VÉRIFIER ce
207
+ * que `filter` exprime, un store pouvant l'ignorer.
208
+ * @returns le nombre de sessions détruites.
209
+ * @throws Error si le store actif ne sait pas énumérer ses sessions.
210
+ */
211
+ destroyWhere(filter: ISessionListFilter | undefined, match: (data: ISerializedSession) => boolean): Promise<number>;
194
212
  /**
195
213
  * « Déconnexion partout » : détruit TOUTES les sessions d'un utilisateur (scan
196
214
  * O(N) — pas d'index inverse, acceptable en admin). Renvoie le nombre détruit.
@@ -1,5 +1,5 @@
1
1
  import { Container, Service, Severity, Msgid, Message, Pci, Pdu, KernelEventsType, nodefonyError, Scope } from "nodefony";
2
- import type { IProfilerQuery } from "nodefony";
2
+ import type { IProfilerQuery, RequestContextPayload } from "nodefony";
3
3
  import type { IRouteResolver, IRequestRouter } from "../../interfaces/IRouting.js";
4
4
  import type { ISecurityZone } from "../../interfaces/ISecurity.js";
5
5
  import { WebSocketServer } from "ws";
@@ -43,7 +43,6 @@ declare class Context extends Service implements IContextInterface {
43
43
  secure: boolean;
44
44
  security?: ISecurityZone | null | undefined;
45
45
  cleaned: boolean;
46
- isControlledAccess: boolean;
47
46
  validDomain: boolean;
48
47
  finished: boolean;
49
48
  pushAllowed: boolean;
@@ -68,6 +67,14 @@ declare class Context extends Service implements IContextInterface {
68
67
  session: Session | null | undefined;
69
68
  cookieSession: Cookie | null | undefined;
70
69
  user: unknown;
70
+ /**
71
+ * Identifiant de l'appelant AUTHENTIFIÉ, posé par le pare-feu ; `null` hors
72
+ * zone ou pour un anonyme. Lisible au démontage, hors de la bulle ALS : c'est
73
+ * ce que lisent le journal d'audit et le profileur. Distinct de `user`, qui
74
+ * lie la session à son principal de SESSION — y écrire l'identité d'un jeton
75
+ * rattacherait la session d'un autre compte.
76
+ */
77
+ userIdentifier: string | null;
71
78
  waitAsync: boolean;
72
79
  isJson: boolean;
73
80
  protected _isHtml: boolean | null;
@@ -116,6 +123,7 @@ declare class Context extends Service implements IContextInterface {
116
123
  private _phaseIndex;
117
124
  private _afterResponseFns;
118
125
  private _afterResponseFired;
126
+ private requestStore;
119
127
  private _abortController;
120
128
  metaData: Data;
121
129
  scheme: SchemeType;
@@ -160,6 +168,18 @@ declare class Context extends Service implements IContextInterface {
160
168
  get signal(): AbortSignal;
161
169
  _abortIfPending(reason?: string): void;
162
170
  log(pci: Pci, severity?: Severity, msgid?: Msgid, msg?: Message): Pdu;
171
+ /**
172
+ * Confie au contexte le magasin `RequestContext` de son unité de travail
173
+ * (requête HTTP, connexion WebSocket), pour qu'il le VIDE à son nettoyage.
174
+ *
175
+ * Un minuteur ou une promesse armé pendant l'unité de travail capture ce
176
+ * magasin et le garde au-delà de la réponse — avec le contexte, l'identité
177
+ * et le jeton du pare-feu. Le vidage ({@link RequestContext.release}) n'y
178
+ * laisse que la corrélation et le scope refermé.
179
+ *
180
+ * @param store - le magasin passé à `RequestContext.run`
181
+ */
182
+ holdRequestStore(store: RequestContextPayload): void;
163
183
  clean(): void;
164
184
  /**
165
185
  * Log d'un event du cycle de vie — gate boot-time (V2.1) : en production le
@@ -13,12 +13,22 @@
13
13
  * Politique de compilation (sûre, ancrée, ReDoS-free) :
14
14
  * - string sans `*` → match EXACT ancré (`^...$`, le `.` est littéral).
15
15
  * - string avec `*` → wildcard UN label (`*.example.com` → `^[^.]+\.example\.com$`,
16
- * RFC 6125 TLS-wildcard : matche `img.example.com`, pas `a.b.example.com` ni `example.com`).
16
+ * RFC 9525 TLS-wildcard : matche `img.example.com`, pas `a.b.example.com` ni `example.com`).
17
17
  * - `RegExp` → reprise telle quelle (l'auteur assume l'ancrage).
18
18
  *
19
19
  * Compilation faite UNE fois (boot / enregistrement de route) ; le test par
20
20
  * requête est un simple `RegExp.test` sur une liste pré-compilée (zéro alloc hot-path).
21
21
  */
22
+ /**
23
+ * Nom d'hôte d'une autorité brute (`host[:port]`), sans port — la valeur que
24
+ * rendrait `URL.hostname` pour une autorité déjà canonique, sans en payer le
25
+ * parse (`indexOf` + `slice`, #508). Un littéral IPv6 garde ses crochets
26
+ * (`[::1]:5151` → `[::1]`) : couper au premier `:` le réduirait à `[`.
27
+ *
28
+ * @param host - autorité brute (`Host`), éventuellement absente
29
+ * @returns le nom d'hôte, `""` si l'autorité est absente
30
+ */
31
+ export declare function hostnameOf(host: string | undefined): string;
22
32
  /** Un pattern de domaine : string (exact ou `*`-wildcard) ou `RegExp` (libre). */
23
33
  export type DomainPattern = string | RegExp;
24
34
  /**
@@ -30,11 +30,24 @@ export interface ProxyType {
30
30
  proxyRealIp?: string | undefined;
31
31
  proxyVia?: string | undefined;
32
32
  }
33
+ /**
34
+ * Même URL (chemin, requête, hôte) sous un autre schéma et un autre port.
35
+ * `port` absent → port par défaut du schéma (omis de l'URL).
36
+ *
37
+ * @param from - URL de la requête.
38
+ * @param scheme - schéma cible.
39
+ * @param port - port du serveur cible, s'il est connu.
40
+ * @returns l'URL absolue de redirection.
41
+ */
42
+ export declare function switchUrlScheme(from: URL, scheme: "http" | "https", port?: number): string;
33
43
  export type HttpRequestType = Http2Request | HttpRequest;
34
44
  export type HttpRsponseType = Http2Response | HttpResponse;
35
45
  import type { IHttpContext as IHttpContextInterface } from "../../../interfaces/IContext.js";
46
+ import { type ISseStreamOptions, type SseStream } from "./SseStream.js";
36
47
  declare class HttpContext extends Context implements IHttpContextInterface {
37
48
  proxy: ProxyType | null;
49
+ /** Socket h1 inscrite dans `socketActiveContext` — retirée par `clean()`. */
50
+ private activeSocket;
38
51
  isRedirect: boolean;
39
52
  sended: boolean;
40
53
  request: HttpRequestType;
@@ -71,6 +84,16 @@ declare class HttpContext extends Context implements IHttpContextInterface {
71
84
  */
72
85
  handle(): MaybePromise<this>;
73
86
  setTimeout(): void;
87
+ /**
88
+ * Nettoie le contexte, et le DÉTACHE de sa socket h1 : la connexion
89
+ * persistante survit à la requête, et l'entrée de `socketActiveContext`
90
+ * retiendrait sinon tout le contexte jusqu'à sa fermeture. Seule l'entrée qui
91
+ * pointe encore sur CE contexte est retirée — une requête suivante sur la
92
+ * même socket a pu la réécrire. Le gestionnaire `timeout` de la socket ne
93
+ * trouve alors rien et prend le chemin « socket inactive → `destroy()` »,
94
+ * celui qu'il prenait déjà pour un contexte nettoyé.
95
+ */
96
+ clean(): void;
74
97
  /**
75
98
  * Sérialise la donnée selon son type (JSON, HTML, texte), pose statut et
76
99
  * en-têtes, puis l'envoie ({@link send}). Synchrone quand l'envoi l'est (#505).
@@ -115,6 +138,16 @@ declare class HttpContext extends Context implements IHttpContextInterface {
115
138
  * @returns la réponse, ou sa promesse quand l'écriture attend.
116
139
  */
117
140
  write(chunk: unknown, encoding?: BufferEncoding, _flush?: boolean): MaybePromise<Http2Response | HttpResponse>;
141
+ /**
142
+ * Répond par un flux d'événements serveur (`text/event-stream`).
143
+ *
144
+ * La requête ne se termine qu'à `close()` du flux, ou au départ du client.
145
+ *
146
+ * @param options - battement de cœur, délai `retry:` initial.
147
+ * @returns le flux, ou sa promesse quand une session est à sauver d'abord.
148
+ * @throws HttpError 500 quand la réponse est déjà partie.
149
+ */
150
+ openSse(options?: ISseStreamOptions): SseStream | Promise<SseStream>;
118
151
  flush(chunk: unknown, encoding: BufferEncoding): MaybePromise<HttpResponse>;
119
152
  /**
120
153
  * Termine la requête : hook `onClose`, puis fin de la réponse si l'envoi ne
@@ -126,7 +159,21 @@ declare class HttpContext extends Context implements IHttpContextInterface {
126
159
  /** Fin de la réponse, sauf si l'envoi l'a déjà terminée. */
127
160
  private endResponse;
128
161
  redirect(Url: string, status?: number | string, headers?: Record<string, string | number>): HttpResponse;
162
+ /**
163
+ * Redirige vers la même URL en HTTPS, sur le port du serveur HTTPS lié —
164
+ * ou le port par défaut derrière un proxy, dont le port public est inconnu.
165
+ *
166
+ * @param status - code de redirection (défaut de `redirect`).
167
+ * @param headers - en-têtes ajoutés à la réponse.
168
+ */
129
169
  redirectHttps(status?: number | string, headers?: Record<string, string | number>): HttpResponse;
170
+ /**
171
+ * Redirige vers la même URL en HTTP, sur le port du serveur HTTP lié — ou
172
+ * le port par défaut derrière un proxy.
173
+ *
174
+ * @param status - code de redirection (défaut de `redirect`).
175
+ * @param headers - en-têtes ajoutés à la réponse.
176
+ */
130
177
  redirectHttp(status?: number | string, headers?: Record<string, string | number>): HttpResponse;
131
178
  getHostName(): string;
132
179
  getRemoteAddress(): string | null;
@@ -22,6 +22,9 @@ declare class HttpResponse {
22
22
  * n'a été choisi (ni négociation, ni `render`, ni controller), émet le défaut
23
23
  * `this.contentType` (application/octet-stream) — comportement identique à
24
24
  * l'ancienne pose au constructeur, sans le ping-pong set/remove/re-set.
25
+ *
26
+ * Une réponse SANS corps n'annonce aucun type : `Content-Type` décrit le
27
+ * contenu (RFC 9110 §8.3), et il n'y en a pas.
25
28
  */
26
29
  protected ensureContentTypeHeader(): void;
27
30
  /**
@@ -0,0 +1,155 @@
1
+ import type HttpContext from "./HttpContext.js";
2
+ /**
3
+ * Le flux d'événements serveur (SSE) d'une réponse HTTP — `text/event-stream`.
4
+ *
5
+ * SSE n'est pas une méthode : c'est un FORMAT de réponse, choisi à l'exécution
6
+ * par l'action. La route reste déclarée `GET` ou `POST`, et le même `POST` peut
7
+ * répondre en JSON ou en flux selon l'en-tête `Accept`.
8
+ *
9
+ * Ce que la classe porte, et qu'une action écrite à la main oubliait :
10
+ * - les en-têtes du flux (`Cache-Control: no-cache, no-transform`,
11
+ * `X-Accel-Buffering: no` pour qu'un nginx ne mette pas le flux en tampon) ;
12
+ * - HTTP/1.1 ET HTTP/2 : en HTTP/1.1 Node découpe le corps en morceaux
13
+ * (RFC 9112 §7.1) ; en HTTP/2 l'écriture passe par le FLUX, et aucun
14
+ * `Transfer-Encoding` n'est posé (interdit, RFC 9113 §8.2.2) ;
15
+ * - la fermeture lue sur la RÉPONSE, jamais sur la requête : en HTTP/2,
16
+ * `request.on("close")` part dès la fin du corps de la requête — c'est le
17
+ * défaut qui avait tué l'ancien flux de la console d'administration ;
18
+ * - le délai d'inactivité de la requête DÉSARMÉ (un flux n'a pas de fin
19
+ * attendue), remplacé par un battement de cœur sur un minuteur `unref` ;
20
+ * - la contre-pression : un client lent freine l'émetteur qui attend `send()` ;
21
+ * un client qui ne lit PLUS est coupé au bout de `stallTimeout` ;
22
+ * - les gardes des connexions longues, les mêmes que pour une socket
23
+ * WebSocket : plafond de connexions par IP (`wsMaxConnectionsPerIp`, un seul
24
+ * budget pour les deux transports) et fermeture du flux quand l'identité qui
25
+ * l'a ouvert est révoquée (`RevocationWatch`, cœur).
26
+ *
27
+ * Norme : WHATWG HTML §9.2 (copie hors ligne
28
+ * `nodefony-framework-dev/references/rfc/specs/whatwg-sse.md`).
29
+ */
30
+ /** Options d'ouverture d'un flux. */
31
+ export interface ISseStreamOptions {
32
+ /**
33
+ * Période du battement de cœur (commentaire `:`), en ms entre 1 et 2^31-1 —
34
+ * `false` pour l'éteindre. Défaut : 15 000, la valeur que la norme suggère
35
+ * contre les proxys qui coupent une connexion muette (§9.2.7).
36
+ */
37
+ heartbeat?: number | false | undefined;
38
+ /** Délai de reconnexion imposé au client (champ `retry:`), en ms. */
39
+ retry?: number | undefined;
40
+ /**
41
+ * Délai maximal pendant lequel une écriture peut rester bloquée — le client
42
+ * ne lit plus —, en ms entre 1 et 2^31-1 ; au-delà, la connexion est coupée.
43
+ * Remplace le délai d'inactivité de la requête, désarmé pendant un flux.
44
+ * `false` pour l'éteindre. Défaut : 30 000.
45
+ */
46
+ stallTimeout?: number | false | undefined;
47
+ /**
48
+ * Taille maximale d'un événement écrit, en octets (UTF-8). Au-delà, `send()`
49
+ * lève une `RangeError` au lieu d'écrire. Défaut : 1 Mio.
50
+ */
51
+ maxEventBytes?: number | undefined;
52
+ /**
53
+ * Durée de vie maximale du flux, en ms entre 1 et 2^31-1 : il est fermé
54
+ * ensuite, et un `EventSource` se reconnecte — en repassant par le pare-feu.
55
+ * `false` (défaut) : pas de borne.
56
+ */
57
+ maxDuration?: number | false | undefined;
58
+ }
59
+ /** Ce qu'un événement porte en plus de ses données. */
60
+ export interface ISseSendOptions {
61
+ /** Type de l'événement (champ `event:`) — absent : `message`. */
62
+ event?: string | undefined;
63
+ /** Identifiant (champ `id:`) que le client renverra en `Last-Event-ID`. */
64
+ id?: string | undefined;
65
+ }
66
+ /** Battement de cœur par défaut, en ms (§9.2.7 : « every 15 seconds or so »). */
67
+ export declare const SSE_HEARTBEAT_MS = 15000;
68
+ /** Délai de blocage d'écriture par défaut, en ms. */
69
+ export declare const SSE_STALL_TIMEOUT_MS = 30000;
70
+ /** Taille maximale d'un événement par défaut, en octets (1 Mio). */
71
+ export declare const SSE_MAX_EVENT_BYTES = 1048576;
72
+ /**
73
+ * Un flux `text/event-stream` ouvert sur une réponse.
74
+ *
75
+ * Il s'obtient par {@link openSseStream} (ou `this.renderSse()` dans un
76
+ * contrôleur), jamais par `new` : l'ouverture écrit les en-têtes, et un flux
77
+ * sans en-têtes ne se rattrape pas.
78
+ */
79
+ export declare class SseStream {
80
+ #private;
81
+ /** Le flux est-il fermé — par le serveur (`close()`) ou par le client parti ? */
82
+ get closed(): boolean;
83
+ /**
84
+ * Envoie un événement.
85
+ *
86
+ * @param data - texte envoyé tel quel, ou valeur sérialisée en JSON ; un
87
+ * texte de plusieurs lignes part en autant de champs `data:`.
88
+ * @param options - type (`event`) et identifiant (`id`) de l'événement.
89
+ * @returns rien quand l'écriture est acceptée ; une promesse réglée au
90
+ * `drain` quand le client lit moins vite qu'on écrit — l'attendre borne la
91
+ * mémoire du serveur. Sans effet une fois le flux fermé.
92
+ * @throws TypeError quand `event` ou `id` contient une fin de ligne.
93
+ * @throws RangeError quand l'événement dépasse `maxEventBytes`.
94
+ */
95
+ send(data: unknown, options?: ISseSendOptions): Promise<void> | undefined;
96
+ /**
97
+ * Envoie un commentaire — ignoré par le client, utile pour garder une
98
+ * connexion vivante ou marquer le flux à la lecture.
99
+ *
100
+ * @returns comme {@link send}.
101
+ */
102
+ comment(text?: string): Promise<void> | undefined;
103
+ /**
104
+ * Impose au client un délai de reconnexion (champ `retry:`).
105
+ *
106
+ * @param ms - délai en millisecondes, entier positif.
107
+ * @returns comme {@link send}.
108
+ * @throws RangeError quand `ms` n'est pas un entier entre 0 et 2^31-1.
109
+ */
110
+ retry(ms: number): Promise<void> | undefined;
111
+ /**
112
+ * Appelle `listener` à la fermeture du flux, quelle qu'en soit la cause.
113
+ *
114
+ * L'écouteur est lié au contexte asynchrone de la requête au moment de
115
+ * l'enregistrement : la fermeture par le client part de la SOCKET, hors de
116
+ * la bulle de la requête, et un `RequestContext.get()` y rendrait `undefined`.
117
+ * Un flux déjà fermé appelle l'écouteur au tour suivant.
118
+ */
119
+ onClose(listener: () => void): void;
120
+ /**
121
+ * Ferme le flux et termine la requête. Idempotent.
122
+ *
123
+ * @returns la fin de la requête (hooks `onClose` du contexte compris).
124
+ */
125
+ close(): Promise<void> | undefined;
126
+ }
127
+ /**
128
+ * Le client accepte-t-il un flux d'événements ? Lit l'en-tête `Accept`.
129
+ *
130
+ * Un même `POST` répond en JSON ou en flux selon ce que demande le client
131
+ * (porte MCP) : c'est cette fonction qui tranche, et elle seule. Une plage
132
+ * `q=0` est un REFUS explicite (RFC 9110 §12.4.2) ; `*\/*` ne suffit pas — un
133
+ * client qui ne nomme pas le flux ne sait pas le lire.
134
+ *
135
+ * @param accept - valeur de l'en-tête `Accept`, absente ou multiple.
136
+ * @returns `true` quand `text/event-stream` est nommé avec un poids non nul.
137
+ */
138
+ export declare function acceptsEventStream(accept: string | string[] | undefined): boolean;
139
+ /**
140
+ * Ouvre un flux d'événements sur la réponse d'un contexte HTTP.
141
+ *
142
+ * Sauve la session d'abord — ses cookies partent avec les en-têtes, qu'on ne
143
+ * pourra plus toucher —, puis écrit les en-têtes du flux et désarme le délai
144
+ * d'inactivité de la requête.
145
+ *
146
+ * @param context - le contexte de la requête, pas encore répondu.
147
+ * @param options - battement de cœur, délai `retry:` initial.
148
+ * @returns le flux ouvert, ou sa promesse quand une session est à sauver.
149
+ * @throws HttpError 500 quand la réponse est déjà partie.
150
+ * @throws HttpError 429 quand l'IP du client a déjà tous ses flux et sockets
151
+ * (`wsMaxConnectionsPerIp`) — refusé AVANT d'écrire quoi que ce soit.
152
+ * @throws RangeError quand un délai n'est ni `false` ni un entier entre 1 et
153
+ * 2^31-1, ou que `maxEventBytes` n'est pas un entier positif.
154
+ */
155
+ export declare function openSseStream(context: HttpContext, options?: ISseStreamOptions): SseStream | Promise<SseStream>;
@@ -0,0 +1,20 @@
1
+ import http2 from "node:http2";
2
+ import type http from "node:http";
3
+ /**
4
+ * La réponse a-t-elle été TERMINÉE par le serveur ? Règle UNIQUE, pour HTTP/1.1
5
+ * comme pour HTTP/2 — l'écouteur `close` du noyau (client parti → 499) et le
6
+ * timeout par flux de `HttpContext` l'appellent tous deux.
7
+ *
8
+ * Sous HTTP/2, la réponse Nodefony écrit et termine le FLUX
9
+ * (`stream.respond`/`stream.end`), jamais la réponse de compatibilité : son
10
+ * `writableEnded` reste à `false` alors que le client a tout reçu. Mais le
11
+ * flux seul ne suffit pas non plus : un flux ANNULÉ par le pair (RST_STREAM
12
+ * avant la fin) a LUI AUSSI `writableEnded === true`, Node terminant le côté
13
+ * écriture en le détruisant. `aborted` sépare la fin posée par le serveur de la
14
+ * coupure du client — même sémantique que `writableEnded` en HTTP/1.1.
15
+ *
16
+ * @param response - la réponse Node de la requête
17
+ * @returns `true` si le serveur a terminé la réponse (ou son flux HTTP/2) ;
18
+ * `false` s'il ne l'a pas terminée, client parti compris
19
+ */
20
+ export declare function responseEnded(response: http.ServerResponse | http2.Http2ServerResponse): boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * URL d'une requête d'upgrade WebSocket, telle que le pipeline la lit.
3
+ *
4
+ * Seule construction : le contexte WebSocket en tire son chemin et sa requête,
5
+ * et le contrôle d'avant `101` en tire le chemin qu'il confronte aux routes.
6
+ * Deux constructions divergeraient — `//a/b` lu comme un chemin d'un côté, comme
7
+ * un hôte de l'autre — et une route acceptée par l'une serait refusée par
8
+ * l'autre.
9
+ *
10
+ * @param scheme - `ws` ou `wss`
11
+ * @param host - en-tête `Host` de la requête (`localhost` s'il manque)
12
+ * @param rawUrl - cible brute de la requête (`/` si elle manque)
13
+ * @returns l'URL analysée
14
+ * @throws TypeError si l'hôte ou la cible ne forment pas une URL
15
+ */
16
+ export declare function parseWebsocketUrl(scheme: string, host: string | undefined, rawUrl: string | undefined): URL;
@@ -116,6 +116,7 @@ export interface ProfileSummary {
116
116
  /** Forme structurelle minimale lue sur un `Context` (lecture défensive). */
117
117
  interface ProfilableContext {
118
118
  requestId?: string | undefined;
119
+ userIdentifier?: string | null | undefined;
119
120
  type?: string | undefined;
120
121
  scheme?: string | undefined;
121
122
  method?: string | null | undefined;