@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,350 @@
1
+ import HttpError from "../../errors/httpError.js";
2
+ import Http2Response from "../http2/Response.js";
3
+ import { RequestContext } from "nodefony";
4
+ import http from "node:http";
5
+ import { AsyncResource } from "node:async_hooks";
6
+ //#region nodefony/src/context/http/SseStream.ts
7
+ /** Battement de cœur par défaut, en ms (§9.2.7 : « every 15 seconds or so »). */
8
+ const SSE_HEARTBEAT_MS = 15e3;
9
+ /** Délai de blocage d'écriture par défaut, en ms. */
10
+ const SSE_STALL_TIMEOUT_MS = 3e4;
11
+ /** Taille maximale d'un événement par défaut, en octets (1 Mio). */
12
+ const SSE_MAX_EVENT_BYTES = 1048576;
13
+ /**
14
+ * Plus long délai qu'un minuteur sait tenir (2^31-1 ms). Au-delà, Node le
15
+ * ramène à 1 ms : un `retry` qui le dépasse ferait reconnecter le client en
16
+ * rafale, un battement qui le dépasse battrait mille fois par seconde.
17
+ */
18
+ const MAX_TIMER_MS = 2147483647;
19
+ /** Commentaire de battement de cœur — ignoré par tout client conforme. */
20
+ const HEARTBEAT = ":\n\n";
21
+ /** Fin de ligne : CRLF, CR ou LF (§9.2.5). */
22
+ const LINE_BREAK = /\r\n|\r|\n/;
23
+ /**
24
+ * Refuse un champ d'une seule ligne qui contiendrait une fin de ligne.
25
+ *
26
+ * Laisser passer un CR ou un LF dans `event` ou `id` permettrait à une valeur
27
+ * venue d'un utilisateur de clore l'événement et d'en FORGER un autre dans le
28
+ * flux — l'équivalent SSE d'une injection d'en-tête.
29
+ *
30
+ * @throws TypeError quand la valeur contient CR, LF ou NUL.
31
+ */
32
+ function singleLine(name, value) {
33
+ if (/[\r\n\0]/.test(value)) throw new TypeError(`Champ SSE « ${name} » : fin de ligne ou NUL interdits`);
34
+ return value;
35
+ }
36
+ /**
37
+ * Texte d'un événement : la chaîne telle quelle, sinon son JSON.
38
+ *
39
+ * `JSON.stringify` rend `undefined` pour `undefined`, une fonction ou un
40
+ * symbole, et son type `string` le tait : on l'élargit avant de le lire.
41
+ */
42
+ function serialize(data) {
43
+ if (typeof data === "string") return data;
44
+ const json = JSON.stringify(data);
45
+ return typeof json === "string" ? json : "";
46
+ }
47
+ /**
48
+ * Refuse une option de délai hors de ce qu'un minuteur sait tenir.
49
+ *
50
+ * @throws RangeError quand la valeur n'est ni `false` ni un entier entre 1 et 2^31-1.
51
+ */
52
+ function checkTimer(name, value) {
53
+ if (value === void 0 || value === false) return;
54
+ if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_MS) throw new RangeError(`SSE : ${name} entier entre 1 et ${MAX_TIMER_MS} ms, ou false, attendu`);
55
+ }
56
+ /** Préfixe chaque ligne d'un texte par `prefix` — `data: ` ou `: `. */
57
+ function lines(prefix, text) {
58
+ let out = "";
59
+ for (const line of text.split(LINE_BREAK)) out += `${prefix}${line}\n`;
60
+ return out;
61
+ }
62
+ /**
63
+ * Un flux `text/event-stream` ouvert sur une réponse.
64
+ *
65
+ * Il s'obtient par {@link openSseStream} (ou `this.renderSse()` dans un
66
+ * contrôleur), jamais par `new` : l'ouverture écrit les en-têtes, et un flux
67
+ * sans en-têtes ne se rattrape pas.
68
+ */
69
+ var SseStream = class {
70
+ #context;
71
+ #target;
72
+ /** La réponse dont on écoute la fermeture — en HTTP/2, l'objet de compatibilité. */
73
+ #response;
74
+ #stallTimeout;
75
+ #maxEventBytes;
76
+ #revalidate;
77
+ /**
78
+ * Identifiant de connexion de l'identité qui a ouvert le flux (`null` pour
79
+ * un anonyme) — le registre de révocation ferme par lui les flux d'une
80
+ * identité dont les droits ont changé.
81
+ *
82
+ * @internal
83
+ */
84
+ identifier;
85
+ #closed = false;
86
+ #heartbeat = null;
87
+ /** Coupure d'un client qui ne lit plus — armée seulement sous pression. */
88
+ #stall = null;
89
+ /** Fin de vie du flux — armée seulement si `maxDuration` est posé. */
90
+ #lifetime = null;
91
+ /** Écouteurs de fermeture — `null` tant que personne n'en pose (lazy). */
92
+ #onClose = null;
93
+ /** Attente de `drain` en cours, partagée par tous les `send()` sous pression. */
94
+ #drain = null;
95
+ #releaseDrain = null;
96
+ #onResponseClose = () => this.#finish();
97
+ #onDrain = () => this.#settleDrain();
98
+ /**
99
+ * Le client ne lit plus depuis `stallTimeout` : on coupe la connexion, comme
100
+ * s'il était parti — la fermeture de la réponse termine le flux.
101
+ */
102
+ #onStall = () => {
103
+ this.#stall = null;
104
+ this.#target.destroy();
105
+ };
106
+ /** @internal — passer par {@link openSseStream}. */
107
+ constructor(context, target, response, settings) {
108
+ this.#context = context;
109
+ this.#target = target;
110
+ this.#response = response;
111
+ this.#stallTimeout = settings.stallTimeout;
112
+ this.#maxEventBytes = settings.maxEventBytes;
113
+ this.#revalidate = settings.revalidate;
114
+ this.identifier = settings.identifier;
115
+ response.on("close", this.#onResponseClose);
116
+ const heartbeat = settings.heartbeat;
117
+ if (settings.maxDuration !== false) {
118
+ this.#lifetime = setTimeout(() => void this.close(), settings.maxDuration);
119
+ this.#lifetime.unref();
120
+ }
121
+ if (heartbeat !== false && heartbeat > 0) {
122
+ this.#heartbeat = setInterval(() => {
123
+ if (this.#drain === null) this.#target.write(HEARTBEAT);
124
+ }, heartbeat);
125
+ this.#heartbeat.unref();
126
+ }
127
+ }
128
+ /** Le flux est-il fermé — par le serveur (`close()`) ou par le client parti ? */
129
+ get closed() {
130
+ return this.#closed;
131
+ }
132
+ /**
133
+ * L'identité qui a ouvert le flux est-elle toujours valable ? Lu par le
134
+ * registre de révocation du noyau HTTP.
135
+ *
136
+ * @internal
137
+ */
138
+ revalidate() {
139
+ return this.#revalidate === null ? Promise.resolve(true) : this.#revalidate();
140
+ }
141
+ /**
142
+ * Envoie un événement.
143
+ *
144
+ * @param data - texte envoyé tel quel, ou valeur sérialisée en JSON ; un
145
+ * texte de plusieurs lignes part en autant de champs `data:`.
146
+ * @param options - type (`event`) et identifiant (`id`) de l'événement.
147
+ * @returns rien quand l'écriture est acceptée ; une promesse réglée au
148
+ * `drain` quand le client lit moins vite qu'on écrit — l'attendre borne la
149
+ * mémoire du serveur. Sans effet une fois le flux fermé.
150
+ * @throws TypeError quand `event` ou `id` contient une fin de ligne.
151
+ * @throws RangeError quand l'événement dépasse `maxEventBytes`.
152
+ */
153
+ send(data, options) {
154
+ let frame = "";
155
+ if (options?.event !== void 0) frame += `event: ${singleLine("event", options.event)}\n`;
156
+ if (options?.id !== void 0) frame += `id: ${singleLine("id", options.id)}\n`;
157
+ frame += `${lines("data: ", serialize(data))}\n`;
158
+ return this.#write(frame);
159
+ }
160
+ /**
161
+ * Envoie un commentaire — ignoré par le client, utile pour garder une
162
+ * connexion vivante ou marquer le flux à la lecture.
163
+ *
164
+ * @returns comme {@link send}.
165
+ */
166
+ comment(text = "") {
167
+ return this.#write(`${lines(": ", text)}\n`);
168
+ }
169
+ /**
170
+ * Impose au client un délai de reconnexion (champ `retry:`).
171
+ *
172
+ * @param ms - délai en millisecondes, entier positif.
173
+ * @returns comme {@link send}.
174
+ * @throws RangeError quand `ms` n'est pas un entier entre 0 et 2^31-1.
175
+ */
176
+ retry(ms) {
177
+ if (!Number.isInteger(ms) || ms < 0 || ms > MAX_TIMER_MS) throw new RangeError(`retry : entier entre 0 et ${MAX_TIMER_MS} ms attendu`);
178
+ return this.#write(`retry: ${ms}\n\n`);
179
+ }
180
+ /**
181
+ * Appelle `listener` à la fermeture du flux, quelle qu'en soit la cause.
182
+ *
183
+ * L'écouteur est lié au contexte asynchrone de la requête au moment de
184
+ * l'enregistrement : la fermeture par le client part de la SOCKET, hors de
185
+ * la bulle de la requête, et un `RequestContext.get()` y rendrait `undefined`.
186
+ * Un flux déjà fermé appelle l'écouteur au tour suivant.
187
+ */
188
+ onClose(listener) {
189
+ const bound = AsyncResource.bind(listener);
190
+ if (this.#closed) {
191
+ queueMicrotask(bound);
192
+ return;
193
+ }
194
+ this.#onClose ??= [];
195
+ this.#onClose.push(bound);
196
+ }
197
+ /**
198
+ * Ferme le flux et termine la requête. Idempotent.
199
+ *
200
+ * @returns la fin de la requête (hooks `onClose` du contexte compris).
201
+ */
202
+ close() {
203
+ if (this.#closed) return void 0;
204
+ this.#finish();
205
+ const done = this.#context.close();
206
+ return done instanceof Promise ? done.then(() => void 0) : void 0;
207
+ }
208
+ /** Écrit une trame ; une promesse seulement sous contre-pression. */
209
+ #write(frame) {
210
+ if (this.#closed) return void 0;
211
+ const max = this.#maxEventBytes;
212
+ if (frame.length * 3 > max && Buffer.byteLength(frame) > max) throw new RangeError(`SSE : événement de plus de ${max} octets refusé`);
213
+ if (this.#target.write(frame)) return void 0;
214
+ if (this.#drain === null) {
215
+ this.#drain = new Promise((resolve) => {
216
+ this.#releaseDrain = resolve;
217
+ });
218
+ this.#target.once("drain", this.#onDrain);
219
+ if (this.#stallTimeout !== false) {
220
+ this.#stall = setTimeout(this.#onStall, this.#stallTimeout);
221
+ this.#stall.unref();
222
+ }
223
+ }
224
+ return this.#drain;
225
+ }
226
+ #settleDrain() {
227
+ if (this.#stall !== null) {
228
+ clearTimeout(this.#stall);
229
+ this.#stall = null;
230
+ }
231
+ const release = this.#releaseDrain;
232
+ this.#drain = null;
233
+ this.#releaseDrain = null;
234
+ release?.();
235
+ }
236
+ /** Fin du flux : minuteur, écouteurs et attentes libérés, une seule fois. */
237
+ #finish() {
238
+ if (this.#closed) return;
239
+ this.#closed = true;
240
+ this.#response.removeListener("close", this.#onResponseClose);
241
+ this.#target.removeListener("drain", this.#onDrain);
242
+ if (this.#heartbeat !== null) {
243
+ clearInterval(this.#heartbeat);
244
+ this.#heartbeat = null;
245
+ }
246
+ if (this.#lifetime !== null) {
247
+ clearTimeout(this.#lifetime);
248
+ this.#lifetime = null;
249
+ }
250
+ if (this.#revalidate !== null) this.#context.httpKernel?.unwatchStream(this);
251
+ this.#settleDrain();
252
+ const listeners = this.#onClose;
253
+ this.#onClose = null;
254
+ if (listeners !== null) for (const listener of listeners) listener();
255
+ }
256
+ };
257
+ /**
258
+ * Le client accepte-t-il un flux d'événements ? Lit l'en-tête `Accept`.
259
+ *
260
+ * Un même `POST` répond en JSON ou en flux selon ce que demande le client
261
+ * (porte MCP) : c'est cette fonction qui tranche, et elle seule. Une plage
262
+ * `q=0` est un REFUS explicite (RFC 9110 §12.4.2) ; `*\/*` ne suffit pas — un
263
+ * client qui ne nomme pas le flux ne sait pas le lire.
264
+ *
265
+ * @param accept - valeur de l'en-tête `Accept`, absente ou multiple.
266
+ * @returns `true` quand `text/event-stream` est nommé avec un poids non nul.
267
+ */
268
+ function acceptsEventStream(accept) {
269
+ if (accept === void 0) return false;
270
+ const value = Array.isArray(accept) ? accept.join(",") : accept;
271
+ for (const range of value.split(",")) {
272
+ const [type, ...params] = range.split(";");
273
+ if (type?.trim().toLowerCase() !== "text/event-stream") continue;
274
+ const q = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith("q="));
275
+ if (q === void 0 || Number.parseFloat(q.slice(2)) > 0) return true;
276
+ }
277
+ return false;
278
+ }
279
+ /**
280
+ * Ouvre un flux d'événements sur la réponse d'un contexte HTTP.
281
+ *
282
+ * Sauve la session d'abord — ses cookies partent avec les en-têtes, qu'on ne
283
+ * pourra plus toucher —, puis écrit les en-têtes du flux et désarme le délai
284
+ * d'inactivité de la requête.
285
+ *
286
+ * @param context - le contexte de la requête, pas encore répondu.
287
+ * @param options - battement de cœur, délai `retry:` initial.
288
+ * @returns le flux ouvert, ou sa promesse quand une session est à sauver.
289
+ * @throws HttpError 500 quand la réponse est déjà partie.
290
+ * @throws HttpError 429 quand l'IP du client a déjà tous ses flux et sockets
291
+ * (`wsMaxConnectionsPerIp`) — refusé AVANT d'écrire quoi que ce soit.
292
+ * @throws RangeError quand un délai n'est ni `false` ni un entier entre 1 et
293
+ * 2^31-1, ou que `maxEventBytes` n'est pas un entier positif.
294
+ */
295
+ function openSseStream(context, options = {}) {
296
+ checkTimer("heartbeat", options.heartbeat);
297
+ checkTimer("stallTimeout", options.stallTimeout);
298
+ checkTimer("maxDuration", options.maxDuration);
299
+ const maxEventBytes = options.maxEventBytes ?? 1048576;
300
+ if (!Number.isInteger(maxEventBytes) || maxEventBytes < 1) throw new RangeError("SSE : maxEventBytes entier positif attendu");
301
+ if (context.sended || context.finished || context.response.isHeaderSent()) throw new HttpError("SSE : la réponse est déjà partie", 500, context);
302
+ const kernel = context.httpKernel;
303
+ const nodeResponse = context.response.response;
304
+ if (kernel !== null && nodeResponse !== null && !kernel.acquireConnectionSlot(context.request.request, nodeResponse)) throw new HttpError("SSE : trop de connexions ouvertes depuis cette adresse", 429, context);
305
+ const revalidate = kernel?.firewall?.currentRevalidator?.() ?? null;
306
+ const identifier = revalidate === null ? null : RequestContext.getAuthenticatedUser()?.identifier ?? null;
307
+ const settings = {
308
+ heartbeat: options.heartbeat ?? 15e3,
309
+ stallTimeout: options.stallTimeout ?? 3e4,
310
+ maxEventBytes,
311
+ maxDuration: options.maxDuration ?? false,
312
+ revalidate,
313
+ identifier
314
+ };
315
+ context.sended = true;
316
+ if (context.session != null) return context.saveSession().catch((e) => {
317
+ context.log(e, "ERROR", "SESSION-STORE");
318
+ return null;
319
+ }).then(() => startSseStream(context, options, settings));
320
+ return startSseStream(context, options, settings);
321
+ }
322
+ /** En-têtes, désarmement du délai, puis le flux. */
323
+ function startSseStream(context, options, settings) {
324
+ const response = context.response;
325
+ const nodeResponse = response.response;
326
+ if (nodeResponse === null) throw new HttpError("SSE : réponse introuvable", 500, context);
327
+ const stream = response instanceof Http2Response ? response.stream : null;
328
+ const h1 = nodeResponse instanceof http.ServerResponse ? nodeResponse : null;
329
+ let target;
330
+ if (stream !== null) target = stream;
331
+ else if (h1 !== null) target = h1;
332
+ else throw new HttpError("SSE : transport de réponse inconnu", 500, context);
333
+ response.setHeaders({
334
+ "Content-Type": "text/event-stream; charset=utf-8",
335
+ "Cache-Control": "no-cache, no-transform",
336
+ "X-Accel-Buffering": "no"
337
+ });
338
+ context.writeHead(200);
339
+ if (stream !== null) stream.setTimeout(0);
340
+ else if (h1 !== null) {
341
+ h1.flushHeaders();
342
+ h1.socket?.setTimeout(0);
343
+ }
344
+ const sse = new SseStream(context, target, nodeResponse, settings);
345
+ if (settings.revalidate !== null) context.httpKernel?.watchStream(sse);
346
+ if (options.retry !== void 0) sse.retry(options.retry);
347
+ return sse;
348
+ }
349
+ //#endregion
350
+ export { SSE_HEARTBEAT_MS, SSE_MAX_EVENT_BYTES, SSE_STALL_TIMEOUT_MS, SseStream, acceptsEventStream, openSseStream };
@@ -0,0 +1,27 @@
1
+ import http2 from "node:http2";
2
+ //#region nodefony/src/context/responseEnded.ts
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
+ function responseEnded(response) {
21
+ if (response.writableEnded) return true;
22
+ if (!(response instanceof http2.Http2ServerResponse)) return false;
23
+ const stream = response.stream;
24
+ return stream.writableEnded && !stream.aborted;
25
+ }
26
+ //#endregion
27
+ export { responseEnded };
@@ -1,6 +1,7 @@
1
1
  import Cookie from "../../cookies/cookie.js";
2
2
  import { decideSend, readBackpressureOptions } from "./wsBackpressure.js";
3
3
  import { toWsCloseReason } from "./wsCloseReason.js";
4
+ import { RequestContext } from "nodefony";
4
5
  import http from "node:http";
5
6
  import Ws from "ws";
6
7
  //#region nodefony/src/context/websocket/Response.ts
@@ -156,6 +157,14 @@ var WebsocketResponse = class {
156
157
  if (isNaN(status)) status = 500;
157
158
  }
158
159
  if (!status) status = 500;
160
+ const sink = RequestContext.get()?.renderSink;
161
+ if (sink) {
162
+ sink.status = status;
163
+ return {
164
+ code: status,
165
+ message: message ?? ""
166
+ };
167
+ }
159
168
  this.statusCode = status;
160
169
  if (!message) message = WS_CLOSE_DESCRIPTIONS[this.statusCode] ?? http.STATUS_CODES[this.statusCode];
161
170
  this.statusMessage = message ?? "";
@@ -5,6 +5,7 @@ import WebsocketResponse from "./Response.js";
5
5
  import { sanitizeRequestId } from "../requestId.js";
6
6
  import { FrameProfile } from "../../profiler/FrameProfile.js";
7
7
  import { formatWsLogContent } from "./wsLogContent.js";
8
+ import { parseWebsocketUrl } from "./websocketUrl.js";
8
9
  import { RequestContext, logColor } from "nodefony";
9
10
  import Ws from "ws";
10
11
  import { AsyncResource } from "node:async_hooks";
@@ -91,9 +92,7 @@ var WebsocketContext = class extends Context {
91
92
  this.scheme = type === "websocket-secure" ? "wss" : "ws";
92
93
  const incomingId = sanitizeRequestId(req.headers["x-request-id"]);
93
94
  if (incomingId) this.requestId = incomingId;
94
- const host = req.headers.host ?? "localhost";
95
- const rawUrl = req.url ?? "/";
96
- this.wsUrl = new URL(`${this.scheme}://${host}${rawUrl}`);
95
+ this.wsUrl = parseWebsocketUrl(this.scheme, req.headers.host, req.url);
97
96
  this.queryGet = Object.fromEntries(this.wsUrl.searchParams.entries());
98
97
  this.queryRequest = { ...this.queryGet };
99
98
  this.wsPath = this.wsUrl.pathname + this.wsUrl.search;
@@ -0,0 +1,21 @@
1
+ //#region nodefony/src/context/websocket/websocketUrl.ts
2
+ /**
3
+ * URL d'une requête d'upgrade WebSocket, telle que le pipeline la lit.
4
+ *
5
+ * Seule construction : le contexte WebSocket en tire son chemin et sa requête,
6
+ * et le contrôle d'avant `101` en tire le chemin qu'il confronte aux routes.
7
+ * Deux constructions divergeraient — `//a/b` lu comme un chemin d'un côté, comme
8
+ * un hôte de l'autre — et une route acceptée par l'une serait refusée par
9
+ * l'autre.
10
+ *
11
+ * @param scheme - `ws` ou `wss`
12
+ * @param host - en-tête `Host` de la requête (`localhost` s'il manque)
13
+ * @param rawUrl - cible brute de la requête (`/` si elle manque)
14
+ * @returns l'URL analysée
15
+ * @throws TypeError si l'hôte ou la cible ne forment pas une URL
16
+ */
17
+ function parseWebsocketUrl(scheme, host, rawUrl) {
18
+ return new URL(`${scheme}://${host ?? "localhost"}${rawUrl ?? "/"}`);
19
+ }
20
+ //#endregion
21
+ export { parseWebsocketUrl };
@@ -122,18 +122,14 @@ function readSecurity(ctx) {
122
122
  /**
123
123
  * Identité de la requête.
124
124
  *
125
- * `context.user` n'est PAS la source de vérité en zone firewall : le token
126
- * résolu vit dans l'ALS (`RequestContext`), qui n'est plus lisible au teardown
127
- * où `collect()` s'exécute. Sans le repli sur la trace, une requête pleinement
128
- * authentifiée (rôles compris) s'afficherait « anonyme ».
125
+ * Le jeton résolu vit dans l'ALS, qui n'est plus lisible au teardown où
126
+ * `collect()` s'exécute : le pare-feu pose donc l'identifiant de l'appelant
127
+ * authentifié sur `context.userIdentifier` (même source que le journal
128
+ * d'audit). La trace du pare-feu prend le relais pour l'anonyme d'une zone
129
+ * `anonymous`, qu'elle nomme.
129
130
  */
130
131
  function readUser(ctx) {
131
- const u = ctx.user;
132
- if (u && typeof u === "object") {
133
- const username = u.username;
134
- if (typeof username === "string") return username;
135
- }
136
- return ctx.securityTrace?.user ?? null;
132
+ return ctx.userIdentifier ?? ctx.securityTrace?.user ?? null;
137
133
  }
138
134
  //#endregion
139
135
  export { Profiler, Profiler as default };