@nodefony/http 10.0.0-beta.1 → 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.
- package/README.md +5 -3
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +11 -5
- package/dist/nodefony/command/certificatesCommand.js +0 -1
- package/dist/nodefony/config/config.js +48 -7
- package/dist/nodefony/config/defineModuleConfig.js +1 -1
- package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +0 -1
- package/dist/nodefony/service/audit-logger.js +2 -3
- package/dist/nodefony/service/certificates.js +214 -158
- package/dist/nodefony/service/error-renderer.js +1 -1
- package/dist/nodefony/service/http-kernel.js +221 -47
- package/dist/nodefony/service/reverse-proxy.js +336 -0
- package/dist/nodefony/service/servers/server-http.js +3 -3
- package/dist/nodefony/service/servers/server-https.js +3 -3
- package/dist/nodefony/service/servers/server-static.js +4 -7
- package/dist/nodefony/service/servers/server-websocket-secure.js +12 -4
- package/dist/nodefony/service/servers/server-websocket.js +12 -4
- package/dist/nodefony/service/servers/wsHeartbeat.js +31 -15
- package/dist/nodefony/service/sessions/sessions-service.js +30 -9
- package/dist/nodefony/service/trace.js +2 -1
- package/dist/nodefony/service/upload/upload-service.js +3 -3
- package/dist/nodefony/service/x509.js +324 -0
- package/dist/nodefony/src/context/Context.js +28 -1
- package/dist/nodefony/src/context/domainMatcher.js +20 -2
- package/dist/nodefony/src/context/forwarded.js +2 -2
- package/dist/nodefony/src/context/http/HttpContext.js +91 -40
- package/dist/nodefony/src/context/http/Request.js +9 -4
- package/dist/nodefony/src/context/http/Response.js +46 -7
- package/dist/nodefony/src/context/http/SseStream.js +350 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +35 -10
- package/dist/nodefony/src/context/responseEnded.js +27 -0
- package/dist/nodefony/src/context/websocket/Response.js +9 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +2 -3
- package/dist/nodefony/src/context/websocket/websocketUrl.js +21 -0
- package/dist/nodefony/src/profiler/Profiler.js +6 -10
- package/dist/nodefony/src/proxy/forward.js +476 -0
- package/dist/nodefony/src/proxy/rules.js +182 -0
- package/dist/nodefony/src/servers/portBinder.js +57 -2
- package/dist/nodefony/src/servers/upgradeDispatch.js +49 -0
- package/dist/nodefony/src/session/session.js +28 -12
- package/dist/types/index.d.ts +5 -0
- package/dist/types/nodefony/config/config.d.ts +17 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +2 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
- package/dist/types/nodefony/service/certificates.d.ts +126 -50
- package/dist/types/nodefony/service/error-renderer.d.ts +1 -1
- package/dist/types/nodefony/service/http-kernel.d.ts +111 -5
- package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +1 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +1 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
- package/dist/types/nodefony/service/x509.d.ts +86 -0
- package/dist/types/nodefony/src/context/Context.d.ts +22 -2
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +47 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +3 -0
- package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
- package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
- package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +1 -0
- package/dist/types/nodefony/src/proxy/forward.d.ts +156 -0
- package/dist/types/nodefony/src/proxy/rules.d.ts +85 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +10 -46
- package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +39 -0
- package/dist/types/nodefony/src/session/session.d.ts +2 -0
- package/docs/cookies.md +13 -13
- package/docs/index.md +9 -3
- package/docs/observabilite.md +18 -18
- package/docs/rate-limit.md +30 -30
- package/docs/reverse-proxy.md +522 -0
- package/docs/servers.md +115 -78
- package/docs/session.md +61 -61
- package/docs/sse.md +283 -0
- package/docs/upload.md +37 -37
- package/package.json +10 -13
|
@@ -15,6 +15,29 @@ const REDIRECT_STATUS_CODES = /* @__PURE__ */ new Set([
|
|
|
15
15
|
308
|
|
16
16
|
]);
|
|
17
17
|
const NO_CONTENT_LENGTH_STATUS = /* @__PURE__ */ new Set([204, 304]);
|
|
18
|
+
const CONTENT_TYPE_CACHE_MAX = 64;
|
|
19
|
+
const contentTypeCache = /* @__PURE__ */ new Map();
|
|
20
|
+
/**
|
|
21
|
+
* Résout `type` (extension ou type MIME) comme `mime.contentType`, avec
|
|
22
|
+
* mémorisation bornée des résolutions positives.
|
|
23
|
+
*
|
|
24
|
+
* @param type - extension (`json`) ou type MIME (`text/html`).
|
|
25
|
+
* @returns la résolution, ou `null` si `mime-types` ne connaît pas le type.
|
|
26
|
+
*/
|
|
27
|
+
function resolveContentType(type) {
|
|
28
|
+
const hit = contentTypeCache.get(type);
|
|
29
|
+
if (hit !== void 0) return hit;
|
|
30
|
+
const full = mime.contentType(type);
|
|
31
|
+
if (!full) return null;
|
|
32
|
+
const semi = full.indexOf(";");
|
|
33
|
+
const resolved = {
|
|
34
|
+
full,
|
|
35
|
+
bare: semi === -1 ? full : full.slice(0, semi),
|
|
36
|
+
charset: mime.charset(full)
|
|
37
|
+
};
|
|
38
|
+
if (contentTypeCache.size < CONTENT_TYPE_CACHE_MAX) contentTypeCache.set(type, resolved);
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
18
41
|
var HttpResponse = class {
|
|
19
42
|
context;
|
|
20
43
|
response;
|
|
@@ -39,9 +62,24 @@ var HttpResponse = class {
|
|
|
39
62
|
* n'a été choisi (ni négociation, ni `render`, ni controller), émet le défaut
|
|
40
63
|
* `this.contentType` (application/octet-stream) — comportement identique à
|
|
41
64
|
* l'ancienne pose au constructeur, sans le ping-pong set/remove/re-set.
|
|
65
|
+
*
|
|
66
|
+
* Une réponse SANS corps n'annonce aucun type : `Content-Type` décrit le
|
|
67
|
+
* contenu (RFC 9110 §8.3), et il n'y en a pas.
|
|
42
68
|
*/
|
|
43
69
|
ensureContentTypeHeader() {
|
|
44
|
-
if (this.response && !this.response.hasHeader("content-type")) this.response.setHeader("content-type", this.contentType);
|
|
70
|
+
if (this.response && !this.response.hasHeader("content-type") && !this.#isBodyless()) this.response.setHeader("content-type", this.contentType);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Vrai quand la réponse n'a pas de corps : 204 et 304 n'en ont jamais
|
|
74
|
+
* (RFC 9110 §15.3.5, §15.4.5) ; ailleurs, un corps posé et VIDE. Un corps
|
|
75
|
+
* jamais posé ne dit rien — un flux peut suivre l'en-tête — et `HEAD` rend
|
|
76
|
+
* les en-têtes du `GET` (§9.3.2) : tous deux gardent le défaut.
|
|
77
|
+
*/
|
|
78
|
+
#isBodyless() {
|
|
79
|
+
if (NO_CONTENT_LENGTH_STATUS.has(this.statusCode)) return true;
|
|
80
|
+
if (this.context.method === "HEAD") return false;
|
|
81
|
+
if (this.#text !== null) return this.#text === "" && !this.flushing;
|
|
82
|
+
return this.#buffer?.length === 0 && !this.flushing;
|
|
45
83
|
}
|
|
46
84
|
/**
|
|
47
85
|
* Corps de la réponse en octets. Un corps posé en texte n'est encodé qu'à la
|
|
@@ -177,9 +215,9 @@ var HttpResponse = class {
|
|
|
177
215
|
}
|
|
178
216
|
setContentType(type, encoding) {
|
|
179
217
|
if (type && encoding) {
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
182
|
-
const mytype =
|
|
218
|
+
const resolved = resolveContentType(type);
|
|
219
|
+
if (resolved) {
|
|
220
|
+
const mytype = resolved.bare;
|
|
183
221
|
this.contentType = mytype;
|
|
184
222
|
this.encoding = encoding;
|
|
185
223
|
if (mytype === "application/json" || mytype.endsWith("+json")) return this.setHeader("Content-Type", mytype);
|
|
@@ -187,10 +225,11 @@ var HttpResponse = class {
|
|
|
187
225
|
}
|
|
188
226
|
}
|
|
189
227
|
if (type && !encoding) {
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
228
|
+
const resolved = resolveContentType(type);
|
|
229
|
+
if (resolved) {
|
|
230
|
+
const mytype = resolved.full;
|
|
192
231
|
this.contentType = mytype;
|
|
193
|
-
|
|
232
|
+
const charset = resolved.charset;
|
|
194
233
|
if (charset) this.encoding = charset;
|
|
195
234
|
return this.setHeader("Content-Type", mytype);
|
|
196
235
|
}
|
|
@@ -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 };
|
|
@@ -79,20 +79,45 @@ function isCanonicalAuthority(host, scheme) {
|
|
|
79
79
|
if (port.length === 0 || port.charCodeAt(0) === 48) return false;
|
|
80
80
|
if (scheme === "https" && port === "443" || scheme === "http" && port === "80") return false;
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
let labels = 1;
|
|
83
|
+
let start = 0;
|
|
84
|
+
for (let i = 0; i < name.length; i++) if (name.charCodeAt(i) === DOT) {
|
|
85
|
+
if (i === start) return false;
|
|
86
|
+
labels++;
|
|
87
|
+
start = i + 1;
|
|
88
|
+
}
|
|
89
|
+
if (start === name.length) return false;
|
|
90
|
+
if (isDigitsFrom(name, start)) {
|
|
91
|
+
if (labels !== 4) return false;
|
|
92
|
+
return isCanonicalDottedQuad(name);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Les quatre labels (non vides, déjà comptés) forment-ils une dotted-quad
|
|
98
|
+
* canonique ? Chacun : chiffres seuls, 3 au plus, sans zéro de tête, ≤ 255.
|
|
99
|
+
*/
|
|
100
|
+
function isCanonicalDottedQuad(name) {
|
|
101
|
+
let start = 0;
|
|
102
|
+
for (let i = 0; i <= name.length; i++) {
|
|
103
|
+
if (i < name.length && name.charCodeAt(i) !== DOT) continue;
|
|
104
|
+
const len = i - start;
|
|
105
|
+
if (len > 3) return false;
|
|
106
|
+
if (len > 1 && name.charCodeAt(start) === 48) return false;
|
|
107
|
+
let value = 0;
|
|
108
|
+
for (let k = start; k < i; k++) {
|
|
109
|
+
const c = name.charCodeAt(k);
|
|
110
|
+
if (c < 48 || c > 57) return false;
|
|
111
|
+
value = value * 10 + (c - 48);
|
|
90
112
|
}
|
|
113
|
+
if (value > 255) return false;
|
|
114
|
+
start = i + 1;
|
|
91
115
|
}
|
|
92
116
|
return true;
|
|
93
117
|
}
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
/** `s` ne contient-elle que des chiffres ASCII à partir de `from` ? */
|
|
119
|
+
function isDigitsFrom(s, from) {
|
|
120
|
+
for (let i = from; i < s.length; i++) {
|
|
96
121
|
const c = s.charCodeAt(i);
|
|
97
122
|
if (c < 48 || c > 57) return false;
|
|
98
123
|
}
|
|
@@ -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
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
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 };
|