@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
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import Http2Response from "../http2/Response.js";
|
|
3
|
-
import { RequestContext } from "nodefony";
|
|
1
|
+
import { MAX_TIMER_MS, checkStreamTimer, openStreamLifecycle } from "./StreamLifecycle.js";
|
|
4
2
|
import http from "node:http";
|
|
5
|
-
import { AsyncResource } from "node:async_hooks";
|
|
6
3
|
// nodefony/src/context/http/SseStream.ts
|
|
7
4
|
/** Battement de cœur par défaut, en ms (§9.2.7 : « every 15 seconds or so »). */
|
|
8
5
|
const SSE_HEARTBEAT_MS = 15e3;
|
|
@@ -10,12 +7,6 @@ const SSE_HEARTBEAT_MS = 15e3;
|
|
|
10
7
|
const SSE_STALL_TIMEOUT_MS = 3e4;
|
|
11
8
|
/** Taille maximale d'un événement par défaut, en octets (1 Mio). */
|
|
12
9
|
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
10
|
/** Commentaire de battement de cœur — ignoré par tout client conforme. */
|
|
20
11
|
const HEARTBEAT = ":\n\n";
|
|
21
12
|
/** Fin de ligne : CRLF, CR ou LF (§9.2.5). */
|
|
@@ -38,21 +29,19 @@ function singleLine(name, value) {
|
|
|
38
29
|
*
|
|
39
30
|
* `JSON.stringify` rend `undefined` pour `undefined`, une fonction ou un
|
|
40
31
|
* symbole, et son type `string` le tait : on l'élargit avant de le lire.
|
|
32
|
+
*
|
|
33
|
+
* Des octets sont refusés : un flux SSE est du texte UTF-8 (WHATWG HTML
|
|
34
|
+
* §9.2), et `JSON.stringify` les rendrait en objet (`[1,2,255]` →
|
|
35
|
+
* `{"0":1,"1":2,"2":255}`) sans erreur (ADR-0021 S8).
|
|
36
|
+
*
|
|
37
|
+
* @throws TypeError quand `data` est un `ArrayBuffer` ou une vue sur octets.
|
|
41
38
|
*/
|
|
42
39
|
function serialize(data) {
|
|
43
40
|
if (typeof data === "string") return data;
|
|
41
|
+
if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) throw new TypeError("SSE : un flux SSE ne porte que du texte UTF-8, des octets n'y passent pas — les encoder en base64 dans la charge, avec leur type MIME (ex. { mimeType: \"image/png\", data: \"<base64>\" }).");
|
|
44
42
|
const json = JSON.stringify(data);
|
|
45
43
|
return typeof json === "string" ? json : "";
|
|
46
44
|
}
|
|
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
45
|
/** Préfixe chaque ligne d'un texte par `prefix` — `data: ` ou `: `. */
|
|
57
46
|
function lines(prefix, text) {
|
|
58
47
|
let out = "";
|
|
@@ -65,69 +54,53 @@ function lines(prefix, text) {
|
|
|
65
54
|
* Il s'obtient par {@link openSseStream} (ou `this.renderSse()` dans un
|
|
66
55
|
* contrôleur), jamais par `new` : l'ouverture écrit les en-têtes, et un flux
|
|
67
56
|
* sans en-têtes ne se rattrape pas.
|
|
57
|
+
*
|
|
58
|
+
* Typé par une carte d'événements (`ContractServerEmit<C>` : le MÊME contrat
|
|
59
|
+
* que la socket temps réel) : `event:` est un canal de la carte et la charge est
|
|
60
|
+
* celle du canal. Deux formes se lisent sur la même carte — **multiplexé**
|
|
61
|
+
* (`send(data, { event: canal })`, plusieurs canaux sur une connexion) ou **un
|
|
62
|
+
* flux = un canal** (carte d'un seul canal, `send(data)` sans `event`). Sans
|
|
63
|
+
* carte, le flux reste libre (porte MCP, flux hors contrat). Le type n'est pas
|
|
64
|
+
* une validation, et ne coûte rien à l'exécution.
|
|
65
|
+
*
|
|
66
|
+
* @typeParam Events - carte des événements du flux.
|
|
68
67
|
*/
|
|
69
68
|
var SseStream = class {
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
/** La réponse dont on écoute la fermeture — en HTTP/2, l'objet de compatibilité. */
|
|
73
|
-
#response;
|
|
74
|
-
#stallTimeout;
|
|
69
|
+
/** Cycle de vie partagé avec le flux d'octets : fin, gardes, registre. */
|
|
70
|
+
#lifecycle;
|
|
75
71
|
#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
72
|
#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
73
|
/** Attente de `drain` en cours, partagée par tous les `send()` sous pression. */
|
|
94
74
|
#drain = null;
|
|
95
75
|
#releaseDrain = null;
|
|
96
|
-
#onResponseClose = () => this.#finish();
|
|
97
76
|
#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
77
|
/** @internal — passer par {@link openSseStream}. */
|
|
107
|
-
constructor(
|
|
108
|
-
this.#
|
|
109
|
-
this.#target = target;
|
|
110
|
-
this.#response = response;
|
|
111
|
-
this.#stallTimeout = settings.stallTimeout;
|
|
78
|
+
constructor(lifecycle, settings) {
|
|
79
|
+
this.#lifecycle = lifecycle;
|
|
112
80
|
this.#maxEventBytes = settings.maxEventBytes;
|
|
113
|
-
this.#
|
|
114
|
-
this.identifier = settings.identifier;
|
|
115
|
-
response.on("close", this.#onResponseClose);
|
|
81
|
+
lifecycle.onRelease(() => this.#release());
|
|
116
82
|
const heartbeat = settings.heartbeat;
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
this.#lifetime.unref();
|
|
120
|
-
}
|
|
121
|
-
if (heartbeat !== false && heartbeat > 0) {
|
|
83
|
+
if (!lifecycle.closed && heartbeat !== false && heartbeat > 0) {
|
|
84
|
+
const target = lifecycle.target;
|
|
122
85
|
this.#heartbeat = setInterval(() => {
|
|
123
|
-
if (this.#drain === null)
|
|
86
|
+
if (this.#drain === null) target.write(HEARTBEAT);
|
|
124
87
|
}, heartbeat);
|
|
125
88
|
this.#heartbeat.unref();
|
|
126
89
|
}
|
|
127
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Identifiant de connexion de l'identité qui a ouvert le flux (`null` pour
|
|
93
|
+
* un anonyme) — le registre de révocation ferme par lui les flux d'une
|
|
94
|
+
* identité dont les droits ont changé.
|
|
95
|
+
*
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
get identifier() {
|
|
99
|
+
return this.#lifecycle.identifier;
|
|
100
|
+
}
|
|
128
101
|
/** Le flux est-il fermé — par le serveur (`close()`) ou par le client parti ? */
|
|
129
102
|
get closed() {
|
|
130
|
-
return this.#closed;
|
|
103
|
+
return this.#lifecycle.closed;
|
|
131
104
|
}
|
|
132
105
|
/**
|
|
133
106
|
* L'identité qui a ouvert le flux est-elle toujours valable ? Lu par le
|
|
@@ -136,7 +109,7 @@ var SseStream = class {
|
|
|
136
109
|
* @internal
|
|
137
110
|
*/
|
|
138
111
|
revalidate() {
|
|
139
|
-
return this.#
|
|
112
|
+
return this.#lifecycle.revalidate();
|
|
140
113
|
}
|
|
141
114
|
/**
|
|
142
115
|
* Envoie un événement.
|
|
@@ -147,7 +120,9 @@ var SseStream = class {
|
|
|
147
120
|
* @returns rien quand l'écriture est acceptée ; une promesse réglée au
|
|
148
121
|
* `drain` quand le client lit moins vite qu'on écrit — l'attendre borne la
|
|
149
122
|
* mémoire du serveur. Sans effet une fois le flux fermé.
|
|
150
|
-
* @throws TypeError quand `event` ou `id` contient une fin de ligne
|
|
123
|
+
* @throws TypeError quand `event` ou `id` contient une fin de ligne, ou
|
|
124
|
+
* quand `data` est fait d'octets (`Uint8Array`, `Buffer`, `ArrayBuffer`) :
|
|
125
|
+
* un flux SSE ne porte que du texte, les encoder soi-même (base64).
|
|
151
126
|
* @throws RangeError quand l'événement dépasse `maxEventBytes`.
|
|
152
127
|
*/
|
|
153
128
|
send(data, options) {
|
|
@@ -174,7 +149,7 @@ var SseStream = class {
|
|
|
174
149
|
* @throws RangeError quand `ms` n'est pas un entier entre 0 et 2^31-1.
|
|
175
150
|
*/
|
|
176
151
|
retry(ms) {
|
|
177
|
-
if (!Number.isInteger(ms) || ms < 0 || ms >
|
|
152
|
+
if (!Number.isInteger(ms) || ms < 0 || ms > 2147483647) throw new RangeError(`retry : entier entre 0 et ${MAX_TIMER_MS} ms attendu`);
|
|
178
153
|
return this.#write(`retry: ${ms}\n\n`);
|
|
179
154
|
}
|
|
180
155
|
/**
|
|
@@ -186,13 +161,7 @@ var SseStream = class {
|
|
|
186
161
|
* Un flux déjà fermé appelle l'écouteur au tour suivant.
|
|
187
162
|
*/
|
|
188
163
|
onClose(listener) {
|
|
189
|
-
|
|
190
|
-
if (this.#closed) {
|
|
191
|
-
queueMicrotask(bound);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
this.#onClose ??= [];
|
|
195
|
-
this.#onClose.push(bound);
|
|
164
|
+
this.#lifecycle.onClose(listener);
|
|
196
165
|
}
|
|
197
166
|
/**
|
|
198
167
|
* Ferme le flux et termine la requête. Idempotent.
|
|
@@ -200,58 +169,40 @@ var SseStream = class {
|
|
|
200
169
|
* @returns la fin de la requête (hooks `onClose` du contexte compris).
|
|
201
170
|
*/
|
|
202
171
|
close() {
|
|
203
|
-
|
|
204
|
-
this.#finish();
|
|
205
|
-
const done = this.#context.close();
|
|
206
|
-
return done instanceof Promise ? done.then(() => void 0) : void 0;
|
|
172
|
+
return this.#lifecycle.close();
|
|
207
173
|
}
|
|
208
174
|
/** Écrit une trame ; une promesse seulement sous contre-pression. */
|
|
209
175
|
#write(frame) {
|
|
210
|
-
|
|
176
|
+
const lifecycle = this.#lifecycle;
|
|
177
|
+
if (lifecycle.closed) return void 0;
|
|
211
178
|
const max = this.#maxEventBytes;
|
|
212
179
|
if (frame.length * 3 > max && Buffer.byteLength(frame) > max) throw new RangeError(`SSE : événement de plus de ${max} octets refusé`);
|
|
213
|
-
|
|
180
|
+
const target = lifecycle.target;
|
|
181
|
+
if (target.write(frame)) return void 0;
|
|
214
182
|
if (this.#drain === null) {
|
|
215
183
|
this.#drain = new Promise((resolve) => {
|
|
216
184
|
this.#releaseDrain = resolve;
|
|
217
185
|
});
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.#stall = setTimeout(this.#onStall, this.#stallTimeout);
|
|
221
|
-
this.#stall.unref();
|
|
222
|
-
}
|
|
186
|
+
target.once("drain", this.#onDrain);
|
|
187
|
+
lifecycle.armStall();
|
|
223
188
|
}
|
|
224
189
|
return this.#drain;
|
|
225
190
|
}
|
|
226
191
|
#settleDrain() {
|
|
227
|
-
|
|
228
|
-
clearTimeout(this.#stall);
|
|
229
|
-
this.#stall = null;
|
|
230
|
-
}
|
|
192
|
+
this.#lifecycle.clearStall();
|
|
231
193
|
const release = this.#releaseDrain;
|
|
232
194
|
this.#drain = null;
|
|
233
195
|
this.#releaseDrain = null;
|
|
234
196
|
release?.();
|
|
235
197
|
}
|
|
236
|
-
/** Fin du flux :
|
|
237
|
-
#
|
|
238
|
-
|
|
239
|
-
this.#closed = true;
|
|
240
|
-
this.#response.removeListener("close", this.#onResponseClose);
|
|
241
|
-
this.#target.removeListener("drain", this.#onDrain);
|
|
198
|
+
/** Fin du flux, appelée UNE fois par le cycle de vie : battement et attentes libérés. */
|
|
199
|
+
#release() {
|
|
200
|
+
this.#lifecycle.target.removeListener("drain", this.#onDrain);
|
|
242
201
|
if (this.#heartbeat !== null) {
|
|
243
202
|
clearInterval(this.#heartbeat);
|
|
244
203
|
this.#heartbeat = null;
|
|
245
204
|
}
|
|
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
205
|
this.#settleDrain();
|
|
252
|
-
const listeners = this.#onClose;
|
|
253
|
-
this.#onClose = null;
|
|
254
|
-
if (listeners !== null) for (const listener of listeners) listener();
|
|
255
206
|
}
|
|
256
207
|
};
|
|
257
208
|
/**
|
|
@@ -293,56 +244,40 @@ function acceptsEventStream(accept) {
|
|
|
293
244
|
* 2^31-1, ou que `maxEventBytes` n'est pas un entier positif.
|
|
294
245
|
*/
|
|
295
246
|
function openSseStream(context, options = {}) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
247
|
+
checkStreamTimer("SSE", "heartbeat", options.heartbeat);
|
|
248
|
+
checkStreamTimer("SSE", "stallTimeout", options.stallTimeout);
|
|
249
|
+
checkStreamTimer("SSE", "maxDuration", options.maxDuration);
|
|
299
250
|
const maxEventBytes = options.maxEventBytes ?? 1048576;
|
|
300
251
|
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
252
|
const settings = {
|
|
308
253
|
heartbeat: options.heartbeat ?? 15e3,
|
|
309
|
-
|
|
310
|
-
maxEventBytes,
|
|
311
|
-
maxDuration: options.maxDuration ?? false,
|
|
312
|
-
revalidate,
|
|
313
|
-
identifier
|
|
254
|
+
maxEventBytes
|
|
314
255
|
};
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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"
|
|
256
|
+
const opened = openStreamLifecycle(context, {
|
|
257
|
+
label: "SSE",
|
|
258
|
+
stallTimeout: options.stallTimeout ?? 3e4,
|
|
259
|
+
maxDuration: options.maxDuration ?? false
|
|
337
260
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
261
|
+
return opened instanceof Promise ? opened.then((lifecycle) => startSseStream(lifecycle, options, settings)) : startSseStream(opened, options, settings);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* En-têtes, désarmement du délai, puis le flux. Un client parti pendant
|
|
265
|
+
* l'ouverture rend un flux déjà fermé, sans rien écrire.
|
|
266
|
+
*/
|
|
267
|
+
function startSseStream(lifecycle, options, settings) {
|
|
268
|
+
if (!lifecycle.closed) {
|
|
269
|
+
const context = lifecycle.context;
|
|
270
|
+
context.response.setHeaders({
|
|
271
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
272
|
+
"Cache-Control": "no-cache, no-transform",
|
|
273
|
+
"X-Accel-Buffering": "no"
|
|
274
|
+
});
|
|
275
|
+
context.writeHead(200);
|
|
276
|
+
const target = lifecycle.target;
|
|
277
|
+
if (target instanceof http.ServerResponse) target.flushHeaders();
|
|
278
|
+
lifecycle.disarmIdleTimeout();
|
|
343
279
|
}
|
|
344
|
-
const sse = new SseStream(
|
|
345
|
-
if (settings.revalidate !== null) context.httpKernel?.watchStream(sse);
|
|
280
|
+
const sse = new SseStream(lifecycle, settings);
|
|
346
281
|
if (options.retry !== void 0) sse.retry(options.retry);
|
|
347
282
|
return sse;
|
|
348
283
|
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import Http2Response from "../http2/Response.js";
|
|
2
|
+
import HttpError from "../../errors/httpError.js";
|
|
3
|
+
import { RequestContext } from "nodefony";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { AsyncResource } from "node:async_hooks";
|
|
6
|
+
// nodefony/src/context/http/StreamLifecycle.ts
|
|
7
|
+
/**
|
|
8
|
+
* Cycle de vie d'une réponse en flux — la brique commune au flux d'événements
|
|
9
|
+
* (`SseStream`) et au flux d'octets, NON exportée.
|
|
10
|
+
*
|
|
11
|
+
* Elle porte ce qu'un flux doit à la requête qui l'ouvre, quel que soit son
|
|
12
|
+
* format :
|
|
13
|
+
* - la PRISE de la réponse (`sended`), avant toute attente : le noyau ne peut
|
|
14
|
+
* plus répondre par-dessus, et l'avertissement « réponse non envoyée » se tait ;
|
|
15
|
+
* - la session sauvée AVANT les en-têtes — ses cookies partent avec eux ;
|
|
16
|
+
* - la fermeture lue sur la RÉPONSE, écouteur posé de façon SYNCHRONE à
|
|
17
|
+
* l'ouverture : un client parti pendant la sauvegarde de la session est vu ;
|
|
18
|
+
* - le délai d'inactivité de la requête désarmé, remplacé par les gardes du
|
|
19
|
+
* flux : coupure d'un client qui ne lit plus (`stallTimeout`), durée de vie
|
|
20
|
+
* maximale (`maxDuration`) ;
|
|
21
|
+
* - le plafond de connexions longues par IP (`wsMaxConnectionsPerIp`, le budget
|
|
22
|
+
* des sockets WebSocket) et la fermeture à la révocation de l'identité ;
|
|
23
|
+
* - une fin unique, et des écouteurs de fin liés au contexte asynchrone.
|
|
24
|
+
*
|
|
25
|
+
* Le FORMAT (en-têtes, écriture, battement de cœur) appartient au flux qui la
|
|
26
|
+
* porte.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Plus long délai qu'un minuteur sait tenir (2^31-1 ms). Au-delà, Node le
|
|
30
|
+
* ramène à 1 ms : un délai qui le dépasse ferait battre ou couper le flux
|
|
31
|
+
* mille fois par seconde.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_TIMER_MS = 2147483647;
|
|
34
|
+
/**
|
|
35
|
+
* Refuse une option de délai hors de ce qu'un minuteur sait tenir.
|
|
36
|
+
*
|
|
37
|
+
* @param label - préfixe du message (`SSE`, `Flux`).
|
|
38
|
+
* @param name - nom de l'option.
|
|
39
|
+
* @param value - valeur reçue ; `undefined` et `false` passent.
|
|
40
|
+
* @throws RangeError quand la valeur n'est ni `false` ni un entier entre 1 et 2^31-1.
|
|
41
|
+
*
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
function checkStreamTimer(label, name, value) {
|
|
45
|
+
if (value === void 0 || value === false) return;
|
|
46
|
+
if (!Number.isInteger(value) || value < 1 || value > 2147483647) throw new RangeError(`${label} : ${name} entier entre 1 et ${MAX_TIMER_MS} ms, ou false, attendu`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Le cycle de vie d'UN flux ouvert sur une réponse.
|
|
50
|
+
*
|
|
51
|
+
* S'obtient par {@link openStreamLifecycle} ; le constructeur n'est appelé
|
|
52
|
+
* directement que par les tests.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
var StreamLifecycle = class {
|
|
57
|
+
context;
|
|
58
|
+
/** Où l'on écrit : la réponse HTTP/1.1, ou le flux HTTP/2. */
|
|
59
|
+
target;
|
|
60
|
+
/** La réponse dont on écoute la fermeture — en HTTP/2, l'objet de compatibilité. */
|
|
61
|
+
#response;
|
|
62
|
+
#stallTimeout;
|
|
63
|
+
#revalidate;
|
|
64
|
+
identifier;
|
|
65
|
+
#closed = false;
|
|
66
|
+
#cause = null;
|
|
67
|
+
/**
|
|
68
|
+
* Minuteur de la coupure d'un client qui ne lit plus — créé à la PREMIÈRE
|
|
69
|
+
* contre-pression, puis réarmé au reste du délai : jamais un minuteur par
|
|
70
|
+
* épisode (un flux d'octets en connaît un par morceau sur une socket rapide).
|
|
71
|
+
*/
|
|
72
|
+
#stall = null;
|
|
73
|
+
/** Début de l'attente de `drain` en cours (`Date.now()`) ; `0` : aucune. */
|
|
74
|
+
#stallSince = 0;
|
|
75
|
+
/** Fin de vie du flux — armée seulement si `maxDuration` est posé. */
|
|
76
|
+
#lifetime = null;
|
|
77
|
+
/** Nettoyage du flux qui porte la brique — posé une fois, appelé en premier. */
|
|
78
|
+
#release = null;
|
|
79
|
+
/** Écouteurs de fermeture — `null` tant que personne n'en pose (lazy). */
|
|
80
|
+
#onClose = null;
|
|
81
|
+
#onResponseClose = () => this.finish();
|
|
82
|
+
/**
|
|
83
|
+
* Tour du minuteur de blocage. Le client ne lit plus depuis `stallTimeout` :
|
|
84
|
+
* on coupe la connexion, comme s'il était parti — la fermeture de la réponse
|
|
85
|
+
* termine le flux. Une attente plus récente (le client a lu entre-temps) est
|
|
86
|
+
* réarmée au reste de son délai ; plus d'attente du tout : le minuteur s'éteint.
|
|
87
|
+
*/
|
|
88
|
+
#onStall = () => {
|
|
89
|
+
this.#stall = null;
|
|
90
|
+
const limit = this.#stallTimeout;
|
|
91
|
+
if (this.#stallSince === 0 || this.#closed || limit === false) return;
|
|
92
|
+
const waited = Date.now() - this.#stallSince;
|
|
93
|
+
if (waited < limit) {
|
|
94
|
+
this.#stall = setTimeout(this.#onStall, limit - waited);
|
|
95
|
+
this.#stall.unref();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.#cause = "stall";
|
|
99
|
+
this.target.destroy();
|
|
100
|
+
};
|
|
101
|
+
constructor(context, target, response, settings) {
|
|
102
|
+
this.context = context;
|
|
103
|
+
this.target = target;
|
|
104
|
+
this.#response = response;
|
|
105
|
+
this.#stallTimeout = settings.stallTimeout;
|
|
106
|
+
this.#revalidate = settings.revalidate;
|
|
107
|
+
this.identifier = settings.identifier;
|
|
108
|
+
response.on("close", this.#onResponseClose);
|
|
109
|
+
if (settings.maxDuration !== false) {
|
|
110
|
+
this.#lifetime = setTimeout(() => void this.close(), settings.maxDuration);
|
|
111
|
+
this.#lifetime.unref();
|
|
112
|
+
}
|
|
113
|
+
if (this.#revalidate !== null) context.httpKernel?.watchStream(this);
|
|
114
|
+
}
|
|
115
|
+
/** Le flux est-il fermé — par le serveur, ou par le client parti ? */
|
|
116
|
+
get closed() {
|
|
117
|
+
return this.#closed;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Qui a fermé le flux : le serveur, la coupure d'un client qui ne lit plus,
|
|
121
|
+
* ou `null` — client parti, ou flux encore ouvert. Lu par le flux qui porte
|
|
122
|
+
* la brique, dans son nettoyage, pour nommer la raison de la fin.
|
|
123
|
+
*/
|
|
124
|
+
get cause() {
|
|
125
|
+
return this.#cause;
|
|
126
|
+
}
|
|
127
|
+
/** L'identité qui a ouvert le flux est-elle toujours valable ? */
|
|
128
|
+
revalidate() {
|
|
129
|
+
return this.#revalidate === null ? Promise.resolve(true) : this.#revalidate();
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Pose le nettoyage du flux qui porte la brique (minuteurs, attentes) : il
|
|
133
|
+
* passe AVANT les écouteurs de l'application. Un cycle déjà fermé l'appelle
|
|
134
|
+
* aussitôt.
|
|
135
|
+
*/
|
|
136
|
+
onRelease(release) {
|
|
137
|
+
if (this.#closed) {
|
|
138
|
+
release();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.#release = release;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Appelle `listener` à la fermeture du flux, quelle qu'en soit la cause.
|
|
145
|
+
*
|
|
146
|
+
* L'écouteur est lié au contexte asynchrone de la requête au moment de
|
|
147
|
+
* l'enregistrement : la fermeture par le client part de la SOCKET, hors de
|
|
148
|
+
* la bulle de la requête. Un flux déjà fermé appelle l'écouteur au tour
|
|
149
|
+
* suivant.
|
|
150
|
+
*/
|
|
151
|
+
onClose(listener) {
|
|
152
|
+
const bound = AsyncResource.bind(listener);
|
|
153
|
+
if (this.#closed) {
|
|
154
|
+
queueMicrotask(bound);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.#onClose ??= [];
|
|
158
|
+
this.#onClose.push(bound);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Arme la coupure d'un client qui ne lit plus — à appeler quand une écriture
|
|
162
|
+
* attend `drain`. Sans effet si elle est déjà armée ou désactivée.
|
|
163
|
+
*/
|
|
164
|
+
armStall() {
|
|
165
|
+
if (this.#stallTimeout === false || this.#stallSince !== 0 || this.#closed) return;
|
|
166
|
+
this.#stallSince = Date.now();
|
|
167
|
+
if (this.#stall !== null) return;
|
|
168
|
+
this.#stall = setTimeout(this.#onStall, this.#stallTimeout);
|
|
169
|
+
this.#stall.unref();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Désarme la coupure : le client a repris sa lecture. Le minuteur n'est pas
|
|
173
|
+
* détruit — il s'éteindra seul à son tour s'il n'y a plus d'attente, ou
|
|
174
|
+
* resservira à la suivante sans nouvelle allocation.
|
|
175
|
+
*/
|
|
176
|
+
clearStall() {
|
|
177
|
+
this.#stallSince = 0;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Désarme le délai d'inactivité de la requête : un flux n'a pas de fin
|
|
181
|
+
* attendue, ses propres gardes le remplacent.
|
|
182
|
+
*/
|
|
183
|
+
disarmIdleTimeout() {
|
|
184
|
+
const target = this.target;
|
|
185
|
+
if (target instanceof http.ServerResponse) target.socket?.setTimeout(0);
|
|
186
|
+
else target.setTimeout(0);
|
|
187
|
+
}
|
|
188
|
+
/** Ferme le flux et termine la requête. Idempotent. */
|
|
189
|
+
close() {
|
|
190
|
+
if (this.#closed) return void 0;
|
|
191
|
+
this.#cause = "server";
|
|
192
|
+
this.finish();
|
|
193
|
+
const done = this.context.close();
|
|
194
|
+
return done instanceof Promise ? done.then(() => void 0) : void 0;
|
|
195
|
+
}
|
|
196
|
+
/** Fin du flux : minuteurs, écouteurs et registre libérés, une seule fois. */
|
|
197
|
+
finish() {
|
|
198
|
+
if (this.#closed) return;
|
|
199
|
+
this.#closed = true;
|
|
200
|
+
this.#response.removeListener("close", this.#onResponseClose);
|
|
201
|
+
if (this.#lifetime !== null) {
|
|
202
|
+
clearTimeout(this.#lifetime);
|
|
203
|
+
this.#lifetime = null;
|
|
204
|
+
}
|
|
205
|
+
this.#stallSince = 0;
|
|
206
|
+
if (this.#stall !== null) {
|
|
207
|
+
clearTimeout(this.#stall);
|
|
208
|
+
this.#stall = null;
|
|
209
|
+
}
|
|
210
|
+
if (this.#revalidate !== null) this.context.httpKernel?.unwatchStream(this);
|
|
211
|
+
const release = this.#release;
|
|
212
|
+
this.#release = null;
|
|
213
|
+
release?.();
|
|
214
|
+
const listeners = this.#onClose;
|
|
215
|
+
this.#onClose = null;
|
|
216
|
+
if (listeners !== null) for (const listener of listeners) listener();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Ouvre le cycle de vie d'un flux sur la réponse d'un contexte HTTP.
|
|
221
|
+
*
|
|
222
|
+
* Prend la réponse, pose l'écoute de sa fermeture, puis sauve la session —
|
|
223
|
+
* sans rien écrire : les en-têtes appartiennent au flux. Un client parti
|
|
224
|
+
* pendant la sauvegarde rend un cycle déjà fermé (`closed`) : le flux n'écrit
|
|
225
|
+
* alors rien.
|
|
226
|
+
*
|
|
227
|
+
* @param context - le contexte de la requête, pas encore répondu.
|
|
228
|
+
* @param options - gardes du flux et préfixe des messages.
|
|
229
|
+
* @returns le cycle de vie, ou sa promesse quand une session est à sauver.
|
|
230
|
+
* @throws HttpError 500 quand la réponse est déjà partie, ou que son
|
|
231
|
+
* transport n'est pas reconnu.
|
|
232
|
+
* @throws HttpError 429 quand l'IP du client a déjà tous ses flux et sockets
|
|
233
|
+
* (`wsMaxConnectionsPerIp`) — refusé AVANT d'écrire quoi que ce soit.
|
|
234
|
+
*
|
|
235
|
+
* @internal
|
|
236
|
+
*/
|
|
237
|
+
function openStreamLifecycle(context, options) {
|
|
238
|
+
const { label } = options;
|
|
239
|
+
if (context.sended || context.finished || context.response.isHeaderSent()) throw new HttpError(`${label} : la réponse est déjà partie`, 500);
|
|
240
|
+
const response = context.response;
|
|
241
|
+
const nodeResponse = response.response;
|
|
242
|
+
if (nodeResponse === null) throw new HttpError(`${label} : réponse introuvable`, 500, context);
|
|
243
|
+
const stream = response instanceof Http2Response ? response.stream : null;
|
|
244
|
+
let target;
|
|
245
|
+
if (stream !== null) target = stream;
|
|
246
|
+
else if (nodeResponse instanceof http.ServerResponse) target = nodeResponse;
|
|
247
|
+
else throw new HttpError(`${label} : transport de réponse inconnu`, 500, context);
|
|
248
|
+
const gone = target.destroyed;
|
|
249
|
+
const kernel = context.httpKernel;
|
|
250
|
+
if (!gone && kernel !== null && !kernel.acquireConnectionSlot(context.request.request, nodeResponse)) throw new HttpError(`${label} : trop de connexions ouvertes depuis cette adresse`, 429, context);
|
|
251
|
+
const revalidate = kernel?.firewall?.currentRevalidator?.() ?? null;
|
|
252
|
+
const identifier = revalidate === null ? null : RequestContext.getAuthenticatedUser()?.identifier ?? null;
|
|
253
|
+
context.sended = true;
|
|
254
|
+
const lifecycle = new StreamLifecycle(context, target, nodeResponse, {
|
|
255
|
+
stallTimeout: options.stallTimeout,
|
|
256
|
+
maxDuration: options.maxDuration,
|
|
257
|
+
revalidate,
|
|
258
|
+
identifier
|
|
259
|
+
});
|
|
260
|
+
if (gone) {
|
|
261
|
+
lifecycle.finish();
|
|
262
|
+
return lifecycle;
|
|
263
|
+
}
|
|
264
|
+
if (context.session == null) return lifecycle;
|
|
265
|
+
return context.saveSession().catch((e) => {
|
|
266
|
+
context.log(e, "ERROR", "SESSION-STORE");
|
|
267
|
+
return null;
|
|
268
|
+
}).then(() => {
|
|
269
|
+
if (target.destroyed) lifecycle.finish();
|
|
270
|
+
return lifecycle;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
export { MAX_TIMER_MS, StreamLifecycle, checkStreamTimer, openStreamLifecycle };
|