@nodefony/http 10.0.0-beta.5 → 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 -26
- package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
- package/dist/nodefony/config/config.js +4 -2
- package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
- package/dist/nodefony/service/error-renderer.js +6 -4
- package/dist/nodefony/service/http-kernel.js +112 -34
- package/dist/nodefony/service/pretty-request-logger.js +1 -1
- 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/http/ByteStream.js +359 -0
- package/dist/nodefony/src/context/http/HttpContext.js +55 -26
- 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 +8 -4
- package/dist/nodefony/src/context/transport.js +20 -1
- package/dist/nodefony/src/context/websocket/Response.js +4 -2
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +71 -14
- 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/Profiler.js +14 -1
- 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 +6 -0
- package/dist/types/nodefony/config/config.d.ts +4 -0
- package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
- package/dist/types/nodefony/service/error-renderer.d.ts +5 -4
- package/dist/types/nodefony/service/http-kernel.d.ts +15 -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/http/ByteStream.d.ts +109 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +45 -3
- 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 +5 -3
- package/dist/types/nodefony/src/context/transport.d.ts +17 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +40 -0
- 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/Profiler.d.ts +11 -1
- 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/index.md +8 -0
- package/docs/reverse-proxy.md +15 -3
- package/docs/servers.md +26 -15
- package/docs/session.md +3 -3
- package/docs/sse.md +27 -20
- package/package.json +16 -16
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import HttpError from "../../errors/httpError.js";
|
|
2
|
+
import "./SseStream.js";
|
|
3
|
+
import { MAX_TIMER_MS, checkStreamTimer, openStreamLifecycle } from "./StreamLifecycle.js";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { AsyncResource } from "node:async_hooks";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
// nodefony/src/context/http/ByteStream.ts
|
|
8
|
+
/** Erreur d'une source après la fin de son flux : plus personne à prévenir. */
|
|
9
|
+
const IGNORE_LATE_ERROR = () => void 0;
|
|
10
|
+
/**
|
|
11
|
+
* Un flux d'octets en cours sur une réponse — interne : on l'obtient par
|
|
12
|
+
* {@link openByteStream}, qui ne rend que sa promesse de fin.
|
|
13
|
+
*/
|
|
14
|
+
var ByteStream = class {
|
|
15
|
+
#lifecycle;
|
|
16
|
+
#source;
|
|
17
|
+
#settings;
|
|
18
|
+
done;
|
|
19
|
+
#resolve;
|
|
20
|
+
#bytes = 0;
|
|
21
|
+
/** Les en-têtes sont-ils partis ? Posé au premier morceau, ou à la fin. */
|
|
22
|
+
#headed = false;
|
|
23
|
+
/** Raison posée par le flux lui-même ; `null` : le cycle de vie tranchera. */
|
|
24
|
+
#reason = null;
|
|
25
|
+
/** La résolution attend la fin de la requête (fin propre, erreur rendue). */
|
|
26
|
+
#settleLater = false;
|
|
27
|
+
#waitingDrain = false;
|
|
28
|
+
/** Octets vus au dernier tour de garde où la source avait avancé. */
|
|
29
|
+
#seenBytes = 0;
|
|
30
|
+
/** Heure de ce tour (`Date.now()`) — une lecture par tour, jamais par morceau. */
|
|
31
|
+
#seenAt = 0;
|
|
32
|
+
#watchdog = null;
|
|
33
|
+
/** `Content-Length` annoncé, lu une fois aux en-têtes ; `-1` : aucun. */
|
|
34
|
+
#declared = -1;
|
|
35
|
+
/** Requête HEAD : aucun corps, la longueur annoncée est celle du GET. */
|
|
36
|
+
#head = false;
|
|
37
|
+
#onData = (chunk) => this.#write(chunk);
|
|
38
|
+
#onEnd = () => this.#finishClean();
|
|
39
|
+
#onSourceError = (error) => this.#fail(error, "error");
|
|
40
|
+
/** Une source détruite sans erreur ni fin : on la tient pour finie. */
|
|
41
|
+
#onSourceClose = () => {
|
|
42
|
+
if (this.#source.errored === null) this.#finishClean();
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Écouteur `drain` PERMANENT, posé une fois (comme `pipe`) : un `once` par
|
|
46
|
+
* épisode de contre-pression allouait un enrobage par morceau sur une socket
|
|
47
|
+
* rapide, où presque chaque écriture de 64 Kio rend `false`.
|
|
48
|
+
*/
|
|
49
|
+
#onDrain = () => {
|
|
50
|
+
if (!this.#waitingDrain) return;
|
|
51
|
+
this.#waitingDrain = false;
|
|
52
|
+
this.#lifecycle.clearStall();
|
|
53
|
+
this.#source.resume();
|
|
54
|
+
};
|
|
55
|
+
#onIdle = () => this.#checkIdle();
|
|
56
|
+
constructor(lifecycle, source, settings) {
|
|
57
|
+
this.#lifecycle = lifecycle;
|
|
58
|
+
this.#source = source;
|
|
59
|
+
this.#settings = settings;
|
|
60
|
+
this.done = new Promise((resolve) => {
|
|
61
|
+
this.#resolve = resolve;
|
|
62
|
+
});
|
|
63
|
+
lifecycle.onRelease(() => this.#release());
|
|
64
|
+
}
|
|
65
|
+
/** Démarre le flux : HEAD, source déjà finie, ou lecture de la source. */
|
|
66
|
+
start() {
|
|
67
|
+
const lifecycle = this.#lifecycle;
|
|
68
|
+
if (lifecycle.closed) return;
|
|
69
|
+
lifecycle.disarmIdleTimeout();
|
|
70
|
+
const source = this.#source;
|
|
71
|
+
source.on("error", this.#onSourceError);
|
|
72
|
+
if (lifecycle.context.getMethod() === "HEAD") {
|
|
73
|
+
this.#head = true;
|
|
74
|
+
this.#finishClean();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (source.errored !== null) {
|
|
78
|
+
this.#fail(source.errored, "error");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (source.destroyed || source.readableEnded) {
|
|
82
|
+
this.#finishClean();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
lifecycle.target.on("drain", this.#onDrain);
|
|
86
|
+
const idle = this.#settings.idleTimeout;
|
|
87
|
+
if (idle > 0) {
|
|
88
|
+
this.#seenAt = Date.now();
|
|
89
|
+
this.#watchdog = setInterval(this.#onIdle, Math.max(1, Math.floor(idle / 4)));
|
|
90
|
+
this.#watchdog.unref();
|
|
91
|
+
}
|
|
92
|
+
source.once("end", this.#onEnd);
|
|
93
|
+
source.once("close", this.#onSourceClose);
|
|
94
|
+
source.on("data", this.#onData);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* En-têtes au premier morceau (ou à la fin d'une source vide).
|
|
98
|
+
*
|
|
99
|
+
* @param first - octets du premier morceau (0 : source vide).
|
|
100
|
+
* @throws Error quand ce morceau contredit déjà `Content-Length` — levé
|
|
101
|
+
* AVANT `writeHead` : rien n'est parti, la page d'erreur le peut.
|
|
102
|
+
*/
|
|
103
|
+
#writeHead(first) {
|
|
104
|
+
const context = this.#lifecycle.context;
|
|
105
|
+
const { headers, live, status } = this.#settings;
|
|
106
|
+
const response = context.response;
|
|
107
|
+
if (headers !== null) response.setHeaders(headers);
|
|
108
|
+
response.setDefaultHeader("Content-Type", "application/octet-stream");
|
|
109
|
+
if (live) {
|
|
110
|
+
response.setDefaultHeader("Cache-Control", "no-cache, no-transform");
|
|
111
|
+
response.setDefaultHeader("X-Accel-Buffering", "no");
|
|
112
|
+
}
|
|
113
|
+
const length = response.getHeader("content-length");
|
|
114
|
+
if (length !== void 0) {
|
|
115
|
+
const declared = Number(length);
|
|
116
|
+
if (Number.isSafeInteger(declared) && declared >= 0) this.#declared = declared;
|
|
117
|
+
}
|
|
118
|
+
if (this.#declared !== -1 && !this.#head && (first === 0 ? this.#declared !== 0 : first > this.#declared)) throw this.#lengthMismatch(first > this.#declared ? "plus" : "moins");
|
|
119
|
+
context.writeHead(status);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Les en-têtes, sans jamais lever : appelé depuis un écouteur `data` ou
|
|
123
|
+
* `end`, une exception (valeur d'en-tête refusée) n'aurait personne pour la
|
|
124
|
+
* rattraper et abattrait le processus. Un échec passe par le chemin d'erreur
|
|
125
|
+
* d'avant le premier octet — rien n'est parti, la page d'erreur peut l'être.
|
|
126
|
+
*
|
|
127
|
+
* @returns `false` quand les en-têtes n'ont pas pu partir.
|
|
128
|
+
*/
|
|
129
|
+
#tryWriteHead(first) {
|
|
130
|
+
try {
|
|
131
|
+
this.#writeHead(first);
|
|
132
|
+
this.#headed = true;
|
|
133
|
+
return true;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
this.#fail(e instanceof Error ? e : new Error(String(e)), "error");
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
#write(chunk) {
|
|
140
|
+
if (this.#reason !== null || this.#lifecycle.closed) return;
|
|
141
|
+
const size = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
|
|
142
|
+
if (!this.#headed && !this.#tryWriteHead(size)) return;
|
|
143
|
+
if (this.#declared !== -1 && this.#bytes + size > this.#declared) {
|
|
144
|
+
this.#fail(this.#lengthMismatch("plus"), "error");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.#bytes += size;
|
|
148
|
+
if (this.#lifecycle.target.write(chunk)) return;
|
|
149
|
+
this.#source.pause();
|
|
150
|
+
this.#waitingDrain = true;
|
|
151
|
+
this.#lifecycle.armStall();
|
|
152
|
+
}
|
|
153
|
+
/** Fin propre : en-têtes s'ils manquent, puis fin de la requête. */
|
|
154
|
+
#finishClean() {
|
|
155
|
+
const lifecycle = this.#lifecycle;
|
|
156
|
+
if (this.#reason !== null || lifecycle.closed) return;
|
|
157
|
+
if (!this.#headed && !this.#tryWriteHead(0)) return;
|
|
158
|
+
if (!this.#head && this.#declared !== -1 && this.#bytes !== this.#declared) {
|
|
159
|
+
this.#fail(this.#lengthMismatch("moins"), "error");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.#reason = "end";
|
|
163
|
+
this.#settleLater = true;
|
|
164
|
+
const closed = lifecycle.close();
|
|
165
|
+
if (closed === void 0) this.#settle();
|
|
166
|
+
else closed.then(this.#settleBound, this.#settleBound);
|
|
167
|
+
}
|
|
168
|
+
#settleBound = () => this.#settle();
|
|
169
|
+
/** L'erreur d'un corps qui ne fait pas la longueur annoncée. */
|
|
170
|
+
#lengthMismatch(direction) {
|
|
171
|
+
return /* @__PURE__ */ new Error(`Flux : la source rend ${direction} d'octets que Content-Length n'en annonce (${this.#declared})`);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Échec de la source, ou source muette. Avant le premier octet, la réponse
|
|
175
|
+
* est ROUVERTE et l'erreur confiée au moteur d'erreurs du noyau — même
|
|
176
|
+
* statut, même page qu'une action ordinaire. Après, le client voit une
|
|
177
|
+
* coupure, jamais une fin propre.
|
|
178
|
+
*/
|
|
179
|
+
#fail(failure, reason) {
|
|
180
|
+
const lifecycle = this.#lifecycle;
|
|
181
|
+
if (this.#reason !== null || lifecycle.closed) return;
|
|
182
|
+
this.#reason = reason;
|
|
183
|
+
const context = lifecycle.context;
|
|
184
|
+
if (!this.#headed && !context.response.isHeaderSent()) {
|
|
185
|
+
this.#settleLater = true;
|
|
186
|
+
lifecycle.finish();
|
|
187
|
+
const kernel = context.httpKernel;
|
|
188
|
+
if (kernel === null) {
|
|
189
|
+
this.#settle();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
context.sended = false;
|
|
193
|
+
this.#renderError(kernel, failure);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (reason === "error") context.log(failure, "ERROR", "STREAM");
|
|
197
|
+
context.response.statusCode = "code" in failure && typeof failure.code === "number" && failure.code >= 400 && failure.code <= 599 ? failure.code : 500;
|
|
198
|
+
const target = lifecycle.target;
|
|
199
|
+
if (target instanceof http.ServerResponse) target.destroy();
|
|
200
|
+
else target.destroy(failure);
|
|
201
|
+
lifecycle.finish();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Rend l'erreur d'avant le premier octet par le moteur d'erreurs du noyau,
|
|
205
|
+
* puis règle la promesse — qui ne rejette jamais, même si ce rendu échoue.
|
|
206
|
+
*/
|
|
207
|
+
async #renderError(kernel, failure) {
|
|
208
|
+
const context = this.#lifecycle.context;
|
|
209
|
+
try {
|
|
210
|
+
await kernel.onError(failure, context);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
context.log(e, "ERROR", "STREAM");
|
|
213
|
+
}
|
|
214
|
+
this.#settle();
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Un tour du chien de garde, tous les quarts de `idleTimeout`. La source a
|
|
218
|
+
* avancé depuis le tour précédent : on note l'heure. Sinon, et depuis au
|
|
219
|
+
* moins `idleTimeout` : la source est muette.
|
|
220
|
+
*/
|
|
221
|
+
#checkIdle() {
|
|
222
|
+
if (this.#reason !== null || this.#lifecycle.closed) return;
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
if (this.#bytes !== this.#seenBytes || this.#waitingDrain) {
|
|
225
|
+
this.#seenBytes = this.#bytes;
|
|
226
|
+
this.#seenAt = now;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const idle = this.#settings.idleTimeout;
|
|
230
|
+
if (now - this.#seenAt < idle) return;
|
|
231
|
+
this.#fail(new HttpError(`Flux : source muette depuis ${idle} ms`, 504), "timeout");
|
|
232
|
+
}
|
|
233
|
+
/** Nettoyage, appelé UNE fois par le cycle de vie, quelle qu'en soit la cause. */
|
|
234
|
+
#release() {
|
|
235
|
+
const lifecycle = this.#lifecycle;
|
|
236
|
+
const reason = this.#reason ?? (lifecycle.cause === "stall" ? "stall" : lifecycle.cause === "server" ? "end" : "client");
|
|
237
|
+
this.#reason = reason;
|
|
238
|
+
const source = this.#source;
|
|
239
|
+
source.removeListener("data", this.#onData);
|
|
240
|
+
source.removeListener("end", this.#onEnd);
|
|
241
|
+
source.removeListener("close", this.#onSourceClose);
|
|
242
|
+
source.on("error", IGNORE_LATE_ERROR);
|
|
243
|
+
source.removeListener("error", this.#onSourceError);
|
|
244
|
+
source.destroy();
|
|
245
|
+
lifecycle.target.removeListener("drain", this.#onDrain);
|
|
246
|
+
if (this.#watchdog !== null) {
|
|
247
|
+
clearInterval(this.#watchdog);
|
|
248
|
+
this.#watchdog = null;
|
|
249
|
+
}
|
|
250
|
+
const context = lifecycle.context;
|
|
251
|
+
if (reason !== "end") context._abortIfPending(`Flux terminé : ${reason}`);
|
|
252
|
+
context.log(`Flux d'octets terminé (${reason}, ${this.#bytes} octets)`, "DEBUG", "STREAM");
|
|
253
|
+
const onClose = this.#settings.onClose;
|
|
254
|
+
if (onClose !== null) try {
|
|
255
|
+
onClose(reason);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
context.log(e, "ERROR", "STREAM");
|
|
258
|
+
}
|
|
259
|
+
if (!this.#settleLater) this.#settle();
|
|
260
|
+
}
|
|
261
|
+
#settle() {
|
|
262
|
+
this.#resolve({
|
|
263
|
+
reason: this.#reason ?? "end",
|
|
264
|
+
bytes: this.#bytes
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Un flux web (`fetch().body`) lu comme un flux Node.
|
|
270
|
+
*
|
|
271
|
+
* Pas `Readable.fromWeb` : il attend le `ReadableStream` de `node:stream/web`,
|
|
272
|
+
* que le type global d'une application compilée avec la lib DOM ne satisfait
|
|
273
|
+
* pas. La lecture tient en un lecteur, et la destruction ANNULE le flux web —
|
|
274
|
+
* c'est elle qui ferme la connexion amont d'un `fetch`.
|
|
275
|
+
*/
|
|
276
|
+
function fromWebStream(stream) {
|
|
277
|
+
const reader = stream.getReader();
|
|
278
|
+
const onRead = (result) => {
|
|
279
|
+
if (result.done) readable.push(null);
|
|
280
|
+
else readable.push(result.value);
|
|
281
|
+
};
|
|
282
|
+
const onFail = (error) => {
|
|
283
|
+
readable.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
284
|
+
};
|
|
285
|
+
const readable = new Readable({
|
|
286
|
+
read() {
|
|
287
|
+
reader.read().then(onRead, onFail);
|
|
288
|
+
},
|
|
289
|
+
destroy(failure, callback) {
|
|
290
|
+
reader.cancel(failure ?? void 0).catch(IGNORE_LATE_ERROR);
|
|
291
|
+
callback(failure);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return readable;
|
|
295
|
+
}
|
|
296
|
+
/** Refuse une option de flux d'octets hors bornes. */
|
|
297
|
+
function checkOptions(options) {
|
|
298
|
+
const { status, idleTimeout } = options;
|
|
299
|
+
if (status !== void 0 && (!Number.isInteger(status) || status < 200 || status > 599)) throw new RangeError("Flux : status entier entre 200 et 599 attendu");
|
|
300
|
+
if (idleTimeout !== void 0 && (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > 2147483647)) throw new RangeError(`Flux : idleTimeout entier entre 0 et ${MAX_TIMER_MS} ms attendu`);
|
|
301
|
+
checkStreamTimer("Flux", "stallTimeout", options.stallTimeout);
|
|
302
|
+
checkStreamTimer("Flux", "maxDuration", options.maxDuration);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Répond par un flux d'octets sur la réponse d'un contexte HTTP.
|
|
306
|
+
*
|
|
307
|
+
* Prend la réponse à l'appel (le noyau ne peut plus répondre par-dessus) ;
|
|
308
|
+
* les en-têtes partent au premier morceau de la source. La promesse se règle à
|
|
309
|
+
* la fin du flux, quelle qu'en soit la raison, et ne rejette JAMAIS.
|
|
310
|
+
*
|
|
311
|
+
* @param context - le contexte de la requête, pas encore répondu.
|
|
312
|
+
* @param source - flux Node, ou flux web (`fetch().body`) ; jamais en mode objet.
|
|
313
|
+
* @param options - statut, en-têtes, délais, écouteur de fin.
|
|
314
|
+
* @returns la raison de la fin et le nombre d'octets envoyés.
|
|
315
|
+
* @throws TypeError quand la source est en mode objet.
|
|
316
|
+
* @throws RangeError quand une option est hors bornes.
|
|
317
|
+
* @throws HttpError 500 quand la réponse est déjà partie ; 429 quand l'IP a
|
|
318
|
+
* déjà tous ses flux et sockets (`wsMaxConnectionsPerIp`).
|
|
319
|
+
*
|
|
320
|
+
* La source appartient au flux dès l'appel : quand l'appel lève, elle est
|
|
321
|
+
* DÉTRUITE (un flux web est annulé) avant que l'erreur ne remonte.
|
|
322
|
+
*/
|
|
323
|
+
function openByteStream(context, source, options = {}) {
|
|
324
|
+
const readable = source instanceof Readable ? source : fromWebStream(source);
|
|
325
|
+
let opened;
|
|
326
|
+
let settings;
|
|
327
|
+
try {
|
|
328
|
+
checkOptions(options);
|
|
329
|
+
if (readable.readableObjectMode) throw new TypeError("Flux : une source en mode objet ne porte pas d'octets — pour Readable.from(), passer { objectMode: false }.");
|
|
330
|
+
settings = settingsOf(context, options);
|
|
331
|
+
opened = openStreamLifecycle(context, {
|
|
332
|
+
label: "Flux",
|
|
333
|
+
stallTimeout: options.stallTimeout ?? 3e4,
|
|
334
|
+
maxDuration: options.maxDuration ?? false
|
|
335
|
+
});
|
|
336
|
+
} catch (error) {
|
|
337
|
+
readable.on("error", IGNORE_LATE_ERROR);
|
|
338
|
+
readable.destroy();
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
const start = (lifecycle) => {
|
|
342
|
+
const stream = new ByteStream(lifecycle, readable, settings);
|
|
343
|
+
stream.start();
|
|
344
|
+
return stream.done;
|
|
345
|
+
};
|
|
346
|
+
return opened instanceof Promise ? opened.then(start) : start(opened);
|
|
347
|
+
}
|
|
348
|
+
/** Les réglages du flux, défauts résolus sur le contexte. */
|
|
349
|
+
function settingsOf(context, options) {
|
|
350
|
+
const timeout = context.response.timeout;
|
|
351
|
+
return {
|
|
352
|
+
status: options.status ?? 200,
|
|
353
|
+
headers: options.headers ?? null,
|
|
354
|
+
live: options.live ?? true,
|
|
355
|
+
idleTimeout: options.idleTimeout ?? (typeof timeout === "number" ? timeout : 3e4),
|
|
356
|
+
onClose: options.onClose === void 0 ? null : AsyncResource.bind(options.onClose)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
export { openByteStream };
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import HttpError from "../../errors/httpError.js";
|
|
2
|
-
import { responseEnded } from "../responseEnded.js";
|
|
3
|
-
import { alpnProtocolOf } from "../transport.js";
|
|
4
|
-
import Cookie from "../../cookies/cookie.js";
|
|
5
|
-
import Context from "../Context.js";
|
|
6
2
|
import { sanitizeRequestId } from "../requestId.js";
|
|
3
|
+
import Context from "../Context.js";
|
|
7
4
|
import HttpRequest from "./Request.js";
|
|
8
5
|
import HttpResponse from "./Response.js";
|
|
9
6
|
import Http2Request from "../http2/Request.js";
|
|
10
7
|
import Http2Response from "../http2/Response.js";
|
|
8
|
+
import Cookie from "../../cookies/cookie.js";
|
|
11
9
|
import { describeSessionStoreFailure } from "../../session/sessionStoreFailure.js";
|
|
10
|
+
import { responseEnded } from "../responseEnded.js";
|
|
11
|
+
import { alpnProtocolOf } from "../transport.js";
|
|
12
12
|
import { openSseStream } from "./SseStream.js";
|
|
13
|
+
import { openByteStream } from "./ByteStream.js";
|
|
13
14
|
import { finallyMaybe, isPromise, thenMaybe, typeOf } from "nodefony";
|
|
15
|
+
import net from "node:net";
|
|
14
16
|
import url, { URL } from "node:url";
|
|
15
17
|
// nodefony/src/context/http/HttpContext.ts
|
|
16
18
|
/**
|
|
@@ -29,25 +31,34 @@ function switchUrlScheme(from, scheme, port) {
|
|
|
29
31
|
return target.href;
|
|
30
32
|
}
|
|
31
33
|
const socketActiveContext = /* @__PURE__ */ new WeakMap();
|
|
32
|
-
const socketTimeoutArmed = /* @__PURE__ */ new WeakSet();
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Route le délai d'inactivité d'une socket HTTP/1.1 vers la requête qu'elle
|
|
36
|
+
* porte : 408/504 si une requête est en cours, fermeture si la connexion
|
|
37
|
+
* persistante est inactive. Écouteur `timeout` du SERVEUR — un seul pour
|
|
38
|
+
* toutes ses connexions —, que Node appelle avec la socket en argument
|
|
39
|
+
* (`socketOnTimeout` → `server.emit("timeout", socket)`).
|
|
40
|
+
*
|
|
41
|
+
* Pourquoi au serveur et pas sur la socket : Node y pose déjà son propre
|
|
42
|
+
* écouteur `timeout`, et un second faisait créer à `EventEmitter` un tableau
|
|
43
|
+
* d'écouteurs par CONNEXION. Ce site d'allocation, partagé avec tout le
|
|
44
|
+
* processus et toujours survivant (une connexion vit longtemps), finissait
|
|
45
|
+
* par être basculé dans le vieux tas par V8 (pretenuring) — dans un démarrage
|
|
46
|
+
* sur trois environ, mesuré sous charge. Le graphe de chaque requête y
|
|
47
|
+
* restait alors accroché jusqu'à la collecte complète suivante : +45 % de CPU
|
|
48
|
+
* par requête, 14 à 19 collectes complètes au lieu de 3. Un écouteur par
|
|
49
|
+
* serveur supprime aussi une fermeture et une entrée de registre par connexion.
|
|
38
50
|
*
|
|
39
|
-
* @param socket - la socket
|
|
40
|
-
*
|
|
51
|
+
* @param socket - la socket dont le délai a expiré ; une session HTTP/2 (même
|
|
52
|
+
* événement sur un serveur `allowHTTP1`) est laissée à son propre délai
|
|
41
53
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
};
|
|
54
|
+
function routeSocketTimeout(socket) {
|
|
55
|
+
if (!(socket instanceof net.Socket)) return;
|
|
56
|
+
const ctx = socketActiveContext.get(socket);
|
|
57
|
+
if (ctx !== void 0 && !ctx.cleaned && !ctx.response.response?.writableEnded) {
|
|
58
|
+
ctx._onTimeout();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
socket.destroy();
|
|
51
62
|
}
|
|
52
63
|
const PERF_PROBE_SUB = process.env.NF_PERF_PROBE === "1";
|
|
53
64
|
var HttpContext = class extends Context {
|
|
@@ -182,10 +193,6 @@ var HttpContext = class extends Context {
|
|
|
182
193
|
this.activeSocket = socket;
|
|
183
194
|
const ms = this.response.timeout;
|
|
184
195
|
if (socket.timeout !== ms) socket.setTimeout(ms);
|
|
185
|
-
if (!socketTimeoutArmed.has(socket)) {
|
|
186
|
-
socketTimeoutArmed.add(socket);
|
|
187
|
-
socket.on("timeout", idleSocketTimeoutHandler(socket));
|
|
188
|
-
}
|
|
189
196
|
}
|
|
190
197
|
/**
|
|
191
198
|
* Ligne d'accès — pour une requête RELAYÉE, avec le statut que l'amont a
|
|
@@ -346,6 +353,7 @@ var HttpContext = class extends Context {
|
|
|
346
353
|
*
|
|
347
354
|
* La requête ne se termine qu'à `close()` du flux, ou au départ du client.
|
|
348
355
|
*
|
|
356
|
+
* @typeParam Events - carte des événements du flux (cf `SseStream`).
|
|
349
357
|
* @param options - battement de cœur, délai `retry:` initial.
|
|
350
358
|
* @returns le flux, ou sa promesse quand une session est à sauver d'abord.
|
|
351
359
|
* @throws HttpError 500 quand la réponse est déjà partie.
|
|
@@ -353,6 +361,27 @@ var HttpContext = class extends Context {
|
|
|
353
361
|
openSse(options) {
|
|
354
362
|
return openSseStream(this, options);
|
|
355
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Répond par un FLUX D'OCTETS : un `<video src>`, un `fetch()`, un
|
|
366
|
+
* téléchargement ou un outil tiers (curl) le consomment comme n'importe quelle
|
|
367
|
+
* réponse HTTP, une réponse par consommateur.
|
|
368
|
+
*
|
|
369
|
+
* La réponse est prise à l'appel, ses en-têtes partent au premier morceau de
|
|
370
|
+
* la source. La source est DÉTRUITE à la fin, quelle qu'en soit la raison
|
|
371
|
+
* (jamais `unpipe`, qui laisse un producteur bloqué en écriture). Pour des
|
|
372
|
+
* événements texte lus par `EventSource`, voir {@link HttpContext.openSse}.
|
|
373
|
+
* Dans un middleware, la requête est répondue : ne pas appeler la suite.
|
|
374
|
+
*
|
|
375
|
+
* @param source - flux Node, ou flux web (`fetch().body`) ; jamais en mode objet.
|
|
376
|
+
* @param options - statut, en-têtes, délais, écouteur de fin.
|
|
377
|
+
* @returns la raison de la fin et le nombre d'octets ; ne rejette jamais.
|
|
378
|
+
* @throws HttpError 500 quand la réponse est déjà partie, 429 au-delà du
|
|
379
|
+
* plafond de connexions par IP ; TypeError pour une source en mode objet.
|
|
380
|
+
* Quand l'appel lève, la source est DÉTRUITE (un flux web est annulé).
|
|
381
|
+
*/
|
|
382
|
+
openStream(source, options) {
|
|
383
|
+
return openByteStream(this, source, options);
|
|
384
|
+
}
|
|
356
385
|
flush(chunk, encoding) {
|
|
357
386
|
return this.response.flush(chunk, encoding);
|
|
358
387
|
}
|
|
@@ -414,7 +443,7 @@ var HttpContext = class extends Context {
|
|
|
414
443
|
return this.request.getMethod();
|
|
415
444
|
}
|
|
416
445
|
setContentType(type, encoding) {
|
|
417
|
-
|
|
446
|
+
this.response.setContentType(type, encoding);
|
|
418
447
|
}
|
|
419
448
|
resolveIsHtml() {
|
|
420
449
|
return this.request.acceptHtml;
|
|
@@ -437,4 +466,4 @@ var HttpContext = class extends Context {
|
|
|
437
466
|
this.response.setContentType("html", encoding);
|
|
438
467
|
}
|
|
439
468
|
};
|
|
440
|
-
export { HttpContext as default, switchUrlScheme };
|
|
469
|
+
export { HttpContext as default, routeSocketTimeout, switchUrlScheme };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import HttpError from "../../errors/httpError.js";
|
|
2
|
-
import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
|
|
3
1
|
import { hostnameOf } from "../domainMatcher.js";
|
|
4
2
|
import { Parser, ParserJson, ParserQs, ParserRaw, ParserXml, acceptParser } from "./parser.js";
|
|
5
|
-
import { isCanonicalAuthority, splitTarget } from "./urlFastPath.js";
|
|
3
|
+
import { isCanonicalAuthority, splitAbsoluteForm, splitTarget } from "./urlFastPath.js";
|
|
4
|
+
import HttpError from "../../errors/httpError.js";
|
|
5
|
+
import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
|
|
6
6
|
import { RequestContext, useConfig } from "nodefony";
|
|
7
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import { URL } from "node:url";
|
|
@@ -546,8 +546,14 @@ var HttpRequest = class {
|
|
|
546
546
|
host = this.getHost();
|
|
547
547
|
return host === this.#rawHostSource ? this.#rawHostname : hostnameOf(host);
|
|
548
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* L'autorité de la requête : celle de la cible en forme absolue, sinon
|
|
551
|
+
* l'en-tête `Host` (RFC 9112 §3.2.2 — le serveur DOIT alors ignorer `Host`).
|
|
552
|
+
* HTTP/2 lit `:authority` (override).
|
|
553
|
+
*/
|
|
549
554
|
getHost() {
|
|
550
|
-
|
|
555
|
+
const absolute = splitAbsoluteForm(this.request.url);
|
|
556
|
+
return absolute === null ? this.request.headers.host : absolute.authority;
|
|
551
557
|
}
|
|
552
558
|
getRemoteAddress() {
|
|
553
559
|
if (this.forwarded) return this.forwarded.clientIp;
|
|
@@ -556,10 +562,13 @@ var HttpRequest = class {
|
|
|
556
562
|
/**
|
|
557
563
|
* Request-target brut (origin-form) tel que reçu du transport — SOURCE
|
|
558
564
|
* UNIQUE partagée par la concaténation de {@link getFullUrl} et la découpe
|
|
559
|
-
* fast-path du ctor.
|
|
565
|
+
* fast-path du ctor. Une cible en forme absolue est ramenée à sa forme
|
|
566
|
+
* origine, son autorité passant par {@link getHost}. HTTP/2 lit le
|
|
567
|
+
* pseudo-header `:path` (override).
|
|
560
568
|
*/
|
|
561
569
|
getRawTarget() {
|
|
562
|
-
|
|
570
|
+
const target = this.request.url;
|
|
571
|
+
return splitAbsoluteForm(target)?.target ?? target;
|
|
563
572
|
}
|
|
564
573
|
/**
|
|
565
574
|
* Scheme effectif côté client : `Forwarded`/`X-Forwarded-*` résolu de façon
|