@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
|
@@ -21,6 +21,85 @@ const COLON = 58;
|
|
|
21
21
|
* @returns les composants découpés, ou `null` (bail-out → vrai `new URL`).
|
|
22
22
|
*/
|
|
23
23
|
function splitTarget(target) {
|
|
24
|
+
return splitTargetFast(target);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Origine fixe d'un request-target analysé SANS requête HTTP (pont
|
|
28
|
+
* `api.request`) : le chemin y est CONCATÉNÉ, jamais résolu comme relatif — un
|
|
29
|
+
* `//autre/x` reste un pathname `//autre/x`, comme le verrait la porte HTTP,
|
|
30
|
+
* au lieu de devenir une autorité.
|
|
31
|
+
*/
|
|
32
|
+
const DETACHED_ORIGIN = "http://request-target.invalid";
|
|
33
|
+
const HASH = 35;
|
|
34
|
+
const AT = 64;
|
|
35
|
+
/**
|
|
36
|
+
* Découpe un request-target en forme absolue (`http://hôte:port/chemin?q`) :
|
|
37
|
+
* la forme qu'un client envoie à un proxy, et qu'un serveur d'origine DOIT
|
|
38
|
+
* accepter (RFC 9112 §3.2.2). Il DOIT alors ignorer l'en-tête `Host` et prendre
|
|
39
|
+
* l'hôte de la cible — le recoller à `Host` fabriquait un hôte qui n'existe pas.
|
|
40
|
+
*
|
|
41
|
+
* Seuls `http` et `https` sont reconnus (RFC 6455 §4.2.1 vaut aussi pour un
|
|
42
|
+
* handshake WebSocket) ; le schéma de la cible ne remplace jamais celui de la
|
|
43
|
+
* connexion. Une autorité porteuse d'identifiants (`user@hôte`, interdite par
|
|
44
|
+
* RFC 9110 §4.2.4) n'est pas reconnue : `Host` garde la main.
|
|
45
|
+
*
|
|
46
|
+
* Chemin de chaque requête : un seul test de caractère pour la forme origine,
|
|
47
|
+
* aucune allocation.
|
|
48
|
+
*
|
|
49
|
+
* @param target - le request-target brut, tel que reçu.
|
|
50
|
+
* @returns l'autorité et la forme origine, ou `null` pour toute autre forme.
|
|
51
|
+
*/
|
|
52
|
+
function splitAbsoluteForm(target) {
|
|
53
|
+
if (target === void 0 || target.charCodeAt(0) === SLASH) return null;
|
|
54
|
+
const sep = target.indexOf("://");
|
|
55
|
+
if (sep !== 4 && sep !== 5) return null;
|
|
56
|
+
const scheme = target.slice(0, sep).toLowerCase();
|
|
57
|
+
if (scheme !== "http" && scheme !== "https") return null;
|
|
58
|
+
const start = sep + 3;
|
|
59
|
+
let end = start;
|
|
60
|
+
while (end < target.length) {
|
|
61
|
+
const code = target.charCodeAt(end);
|
|
62
|
+
if (code === SLASH || code === QMARK || code === HASH) break;
|
|
63
|
+
if (code === AT) return null;
|
|
64
|
+
end++;
|
|
65
|
+
}
|
|
66
|
+
if (end === start) return null;
|
|
67
|
+
const rest = target.slice(end);
|
|
68
|
+
return {
|
|
69
|
+
authority: target.slice(start, end),
|
|
70
|
+
target: rest.charCodeAt(0) === SLASH ? rest : `/${rest}`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Analyse un request-target origin-form (`/chemin[?query]`) dans la forme
|
|
75
|
+
* NORMALISÉE que la porte HTTP donne au routeur (`HttpRequest`) : découpe exacte
|
|
76
|
+
* quand le parse WHATWG serait l'identité ({@link splitTarget}), vrai parse
|
|
77
|
+
* WHATWG sinon — dot-segments résolus, `\` replié, percent-encoding posé.
|
|
78
|
+
*
|
|
79
|
+
* Pour qui reçoit un chemin hors d'une requête HTTP — le pont WebSocket
|
|
80
|
+
* `api.request` — et doit router exactement comme HTTP : la même chaîne, deux
|
|
81
|
+
* transports, une seule forme.
|
|
82
|
+
*
|
|
83
|
+
* @param target - le chemin reçu ; il doit commencer par `/`.
|
|
84
|
+
* @returns pathname et search normalisés, ou `null` si `target` n'est pas un
|
|
85
|
+
* chemin origin-form analysable.
|
|
86
|
+
*/
|
|
87
|
+
function parseRequestTarget(target) {
|
|
88
|
+
if (target.charCodeAt(0) !== SLASH) return null;
|
|
89
|
+
const fast = splitTargetFast(target);
|
|
90
|
+
if (fast !== null) return fast;
|
|
91
|
+
let url;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(`${DETACHED_ORIGIN}${target}`);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
pathname: url.pathname,
|
|
99
|
+
search: url.search
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function splitTargetFast(target) {
|
|
24
103
|
if (typeof target !== "string" || target.length === 0) return null;
|
|
25
104
|
if (target.charCodeAt(0) !== SLASH) return null;
|
|
26
105
|
let prev = 0;
|
|
@@ -123,4 +202,4 @@ function isDigitsFrom(s, from) {
|
|
|
123
202
|
}
|
|
124
203
|
return true;
|
|
125
204
|
}
|
|
126
|
-
export { isCanonicalAuthority, splitTarget };
|
|
205
|
+
export { isCanonicalAuthority, parseRequestTarget, splitAbsoluteForm, splitTarget };
|
|
@@ -10,8 +10,9 @@ const emptyHeaders = {
|
|
|
10
10
|
getHeader: () => void 0
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
* Compose les en-têtes d'une réponse HTTP/2 : ceux
|
|
14
|
-
* ceux
|
|
13
|
+
* Compose les en-têtes d'une réponse HTTP/2 : ceux qu'un tiers a écrits sur la
|
|
14
|
+
* réponse Node, puis ceux de la réserve de la réponse, puis ceux passés à
|
|
15
|
+
* `writeHead` — chacun l'emporte sur le précédent.
|
|
15
16
|
*
|
|
16
17
|
* Les clés sont ramenées en MINUSCULES : `getHeaders()` rend `content-type`,
|
|
17
18
|
* un contrôleur passe `Content-Type` — fusionnées telles quelles, les deux
|
|
@@ -27,11 +28,13 @@ const emptyHeaders = {
|
|
|
27
28
|
* @param statusMessage - message de statut, recopié en `x-status-message`
|
|
28
29
|
* @param current - la réponse Node qui porte les en-têtes déjà posés
|
|
29
30
|
* @param extra - en-têtes passés à `writeHead` (objet, ou liste plate clé/valeur)
|
|
31
|
+
* @param stored - la réserve de la réponse, liste plate aux noms en minuscules
|
|
30
32
|
* @returns les en-têtes à transmettre à `respond()`
|
|
31
33
|
*/
|
|
32
|
-
function mergeResponseHeaders(statusMessage, current, extra) {
|
|
34
|
+
function mergeResponseHeaders(statusMessage, current, extra, stored) {
|
|
33
35
|
const merged = { "x-status-message": statusMessage };
|
|
34
36
|
for (const name of current.getHeaderNames()) merged[name] = current.getHeader(name);
|
|
37
|
+
if (stored) for (let i = 0; i + 1 < stored.length; i += 2) merged[String(stored[i])] = stored[i + 1];
|
|
35
38
|
if (Array.isArray(extra)) for (let i = 0; i + 1 < extra.length; i += 2) merged[String(extra[i]).toLowerCase()] = extra[i + 1];
|
|
36
39
|
else if (extra) for (const key in extra) {
|
|
37
40
|
const value = extra[key];
|
|
@@ -68,7 +71,8 @@ var Http2Response = class extends HttpResponse {
|
|
|
68
71
|
this.statusMessage = this.getStatusMessage();
|
|
69
72
|
this.setLength();
|
|
70
73
|
this.ensureContentTypeHeader();
|
|
71
|
-
this.
|
|
74
|
+
this.applyHeaderPolicy(this.contentTypeForPolicy());
|
|
75
|
+
this.headers = mergeResponseHeaders(this.statusMessage, this.response ?? emptyHeaders, headers, this.headerList());
|
|
72
76
|
this.headers[HTTP2_HEADER_STATUS] = this.statusCode;
|
|
73
77
|
const requestId = this.context.requestId;
|
|
74
78
|
if (requestId) this.headers["x-request-id"] = requestId;
|
|
@@ -42,4 +42,23 @@ function alpnProtocolOf(request) {
|
|
|
42
42
|
function requestAuthority(headers) {
|
|
43
43
|
return headers[":authority"] ?? headers.host;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Une requête HTTP/2 nomme-t-elle deux hôtes — `:authority` ET un `Host`
|
|
47
|
+
* différent ? RFC 9113 §8.3.1 : « SHOULD treat a request as malformed ». Le
|
|
48
|
+
* serveur lit `:authority` ({@link requestAuthority}), mais un code qui lit
|
|
49
|
+
* `headers.host` verrait un autre hôte que celui que le routage et le
|
|
50
|
+
* pare-feu ont jugé.
|
|
51
|
+
*
|
|
52
|
+
* Chemin de chaque requête : une lecture d'en-tête ; la comparaison n'a lieu
|
|
53
|
+
* que si les deux sont présents (un navigateur n'envoie pas `Host` en HTTP/2).
|
|
54
|
+
*
|
|
55
|
+
* @param headers - en-têtes de la requête Node.
|
|
56
|
+
* @returns `true` quand les deux autorités diffèrent (casse ignorée).
|
|
57
|
+
*/
|
|
58
|
+
function hasConflictingAuthority(headers) {
|
|
59
|
+
const authority = headers[":authority"];
|
|
60
|
+
if (authority === void 0) return false;
|
|
61
|
+
const host = headers.host;
|
|
62
|
+
return host !== void 0 && host.toLowerCase() !== authority.toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
export { alpnProtocolOf, hasConflictingAuthority, protocolNameOf, requestAuthority };
|
|
@@ -215,8 +215,10 @@ var WebsocketResponse = class {
|
|
|
215
215
|
return true;
|
|
216
216
|
}
|
|
217
217
|
addCookie(cookie) {
|
|
218
|
-
if (cookie instanceof Cookie)
|
|
219
|
-
|
|
218
|
+
if (cookie instanceof Cookie) {
|
|
219
|
+
cookie.assertGrammar();
|
|
220
|
+
this.cookies[cookie.name] = cookie;
|
|
221
|
+
} else throw new Error("Response addCookies not valid cookies");
|
|
220
222
|
}
|
|
221
223
|
setCookies() {}
|
|
222
224
|
setCookie(_cookie) {}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
|
|
2
|
-
import { closeRequestTrace } from "../requestTrace.js";
|
|
3
1
|
import Context from "../Context.js";
|
|
4
|
-
import { toWsCloseReason } from "./wsCloseReason.js";
|
|
5
2
|
import WebsocketResponse from "./Response.js";
|
|
3
|
+
import { toWsCloseReason } from "./wsCloseReason.js";
|
|
6
4
|
import { sanitizeRequestId } from "../requestId.js";
|
|
7
5
|
import { FrameProfile } from "../../profiler/FrameProfile.js";
|
|
8
|
-
import { formatWsLogContent } from "./wsLogContent.js";
|
|
6
|
+
import { WS_LOG_CONTENT_CAP, formatWsLogContent } from "./wsLogContent.js";
|
|
7
|
+
import { admitFrame, readFrameRateBound, readFrameRateCloseAfter } from "./wsFrameRate.js";
|
|
8
|
+
import { closeRequestTrace } from "../requestTrace.js";
|
|
9
|
+
import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
|
|
9
10
|
import { parseWebsocketUrl } from "./websocketUrl.js";
|
|
10
|
-
import { RequestContext, frameRequestId, logColor } from "nodefony";
|
|
11
|
+
import { IDENTITY_DEAD_CLOSE_CODE, NOT_FOUND_CLOSE_CODE, ORIGIN_REFUSED_CLOSE_CODE, RequestContext, frameRequestId, logColor } from "nodefony";
|
|
11
12
|
import { AsyncResource } from "node:async_hooks";
|
|
12
13
|
import url, { URL } from "node:url";
|
|
13
14
|
import Ws from "ws";
|
|
@@ -51,7 +52,7 @@ function toWsCloseCode(code) {
|
|
|
51
52
|
if (code === 1e3 || code >= 1001 && code <= 1003 || code >= 1007 && code <= 1011 || code >= 3e3 && code <= 4999) return code;
|
|
52
53
|
if (code >= 500 && code < 600) return 1011;
|
|
53
54
|
if (code === 401 || code === 403 || code === 421) return 1008;
|
|
54
|
-
if (code >= 400 && code < 500) return
|
|
55
|
+
if (code >= 400 && code < 500) return NOT_FOUND_CLOSE_CODE;
|
|
55
56
|
return 1011;
|
|
56
57
|
}
|
|
57
58
|
/**
|
|
@@ -72,11 +73,11 @@ function toHandshakeStatus(code) {
|
|
|
72
73
|
case 1003:
|
|
73
74
|
case 1007: return 400;
|
|
74
75
|
case 1008:
|
|
75
|
-
case
|
|
76
|
-
case
|
|
76
|
+
case ORIGIN_REFUSED_CLOSE_CODE: return 403;
|
|
77
|
+
case IDENTITY_DEAD_CLOSE_CODE: return 401;
|
|
77
78
|
case 1009: return 413;
|
|
78
79
|
case 1013: return 503;
|
|
79
|
-
case
|
|
80
|
+
case NOT_FOUND_CLOSE_CODE: return 404;
|
|
80
81
|
default: return 500;
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -110,11 +111,23 @@ var WebsocketContext = class extends Context {
|
|
|
110
111
|
request;
|
|
111
112
|
response = null;
|
|
112
113
|
acceptedProtocol;
|
|
114
|
+
/**
|
|
115
|
+
* Codec des trames BINAIRES de cette connexion, posé par la couche qui l'a négocié
|
|
116
|
+
* (le temps réel, ADR-0022 §2). Hors production, le traceur s'en sert pour
|
|
117
|
+
* afficher une trame binaire décodée plutôt que sa seule taille ; jamais lu en
|
|
118
|
+
* production. `null` = octets bruts, résumés à leur taille.
|
|
119
|
+
*/
|
|
120
|
+
frameCodec = null;
|
|
113
121
|
port;
|
|
114
122
|
rejected = false;
|
|
115
123
|
teardownWired = false;
|
|
116
124
|
connection = null;
|
|
117
125
|
/**
|
|
126
|
+
* Fenêtre de la borne de débit entrante — `null` tant que la borne est
|
|
127
|
+
* désactivée ou qu'aucune trame n'est arrivée (créée au premier usage).
|
|
128
|
+
*/
|
|
129
|
+
frameRate = null;
|
|
130
|
+
/**
|
|
118
131
|
* Le verdict du handshake encore à rendre : posé par le noyau tant que le
|
|
119
132
|
* pipeline décide (pare-feu, garde de l'action), `null` dès que la socket
|
|
120
133
|
* est acceptée ou refusée. Tant qu'il est là, AUCUN `101` n'est parti.
|
|
@@ -271,7 +284,7 @@ var WebsocketContext = class extends Context {
|
|
|
271
284
|
}
|
|
272
285
|
});
|
|
273
286
|
} else if (!this.isRejected()) {
|
|
274
|
-
this.reject(
|
|
287
|
+
this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
|
|
275
288
|
this.rejected = true;
|
|
276
289
|
this.webSocketState = "error";
|
|
277
290
|
}
|
|
@@ -311,6 +324,28 @@ var WebsocketContext = class extends Context {
|
|
|
311
324
|
return null;
|
|
312
325
|
}
|
|
313
326
|
/**
|
|
327
|
+
* Le contenu des messages est-il journalisé ? Hors production seulement —
|
|
328
|
+
* décidé une fois pour le processus (P8 : runtime ∈ {development, production},
|
|
329
|
+
* `resolveRuntimeEnv`).
|
|
330
|
+
*/
|
|
331
|
+
tracesMessageContent() {
|
|
332
|
+
wsContentLogging ??= this.kernel?.environment !== "production";
|
|
333
|
+
return wsContentLogging;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Traceur des trames SORTANTES écrites hors de {@link send} — le temps réel
|
|
337
|
+
* écrit droit sur la socket. À demander UNE fois par connexion : `null` en
|
|
338
|
+
* production, donc l'appelant choisit son chemin d'écriture à l'ouverture et
|
|
339
|
+
* ne paie rien par trame.
|
|
340
|
+
*
|
|
341
|
+
* @returns une fonction qui journalise une trame comme `send` (`WS SEND`),
|
|
342
|
+
* ou `null` quand le contenu des messages n'est pas journalisé.
|
|
343
|
+
*/
|
|
344
|
+
outboundTracer() {
|
|
345
|
+
if (!this.tracesMessageContent()) return null;
|
|
346
|
+
return (data) => this.logMessageContent("SEND", data);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
314
349
|
* Logge le CONTENU d'un message WS (corrélé `requestId` via l'override `log`),
|
|
315
350
|
* gaté hors prod et borné — le Suivi de requête (Studio) le surface alors par
|
|
316
351
|
* direction. Hot path : le gate booléen court-circuite en prod AVANT toute
|
|
@@ -320,9 +355,8 @@ var WebsocketContext = class extends Context {
|
|
|
320
355
|
* @param data - charge utile (string, Buffer binaire, objet, ou null).
|
|
321
356
|
*/
|
|
322
357
|
logMessageContent(dir, data) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
this.log(formatWsLogContent(data), "DEBUG", `WS ${dir}`);
|
|
358
|
+
if (!this.tracesMessageContent()) return;
|
|
359
|
+
this.log(formatWsLogContent(data, WS_LOG_CONTENT_CAP, this.frameCodec), "DEBUG", `WS ${dir}`);
|
|
326
360
|
}
|
|
327
361
|
/**
|
|
328
362
|
* Ouvre le profil d'**une invocation** du pont RPC (une frame `api.request`).
|
|
@@ -390,7 +424,30 @@ var WebsocketContext = class extends Context {
|
|
|
390
424
|
}
|
|
391
425
|
super.phaseEnd(name);
|
|
392
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Applique la borne de débit entrante à une trame : `true` = l'admettre.
|
|
429
|
+
*
|
|
430
|
+
* Écartée → comptée (`frameRateRejectedTotal`) ; solde de refus atteint →
|
|
431
|
+
* connexion fermée en `1008` (RFC 6455 §7.4.1, violation de politique).
|
|
432
|
+
*
|
|
433
|
+
* @param bound - trames admises par seconde (`> 0`)
|
|
434
|
+
* @returns `true` si la trame poursuit son chemin
|
|
435
|
+
*/
|
|
436
|
+
admitInboundFrame(bound) {
|
|
437
|
+
const now = Date.now();
|
|
438
|
+
this.frameRate ??= {
|
|
439
|
+
start: now,
|
|
440
|
+
count: 0,
|
|
441
|
+
balance: 0
|
|
442
|
+
};
|
|
443
|
+
const verdict = admitFrame(this.frameRate, now, bound, readFrameRateCloseAfter(this.server));
|
|
444
|
+
if (verdict === "send") return true;
|
|
445
|
+
if (verdict === "close") this.connection?.close(1008, "frame rate");
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
393
448
|
async handleMessage(data, isBinary) {
|
|
449
|
+
const bound = readFrameRateBound(this.server);
|
|
450
|
+
if (bound > 0 && !this.admitInboundFrame(bound)) return void 0;
|
|
394
451
|
this.webSocketState = "message";
|
|
395
452
|
const message = isBinary ? data : data.toString();
|
|
396
453
|
this.logMessageContent("RECEIVE", message);
|
|
@@ -406,7 +463,7 @@ var WebsocketContext = class extends Context {
|
|
|
406
463
|
} } });
|
|
407
464
|
return callAsPromise(this.resolver, [message]);
|
|
408
465
|
} else if (!this.rejected) {
|
|
409
|
-
this.reject(
|
|
466
|
+
this.reject(NOT_FOUND_CLOSE_CODE, "Not Found");
|
|
410
467
|
this.rejected = true;
|
|
411
468
|
}
|
|
412
469
|
return;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitAbsoluteForm } from "../http/urlFastPath.js";
|
|
1
2
|
// nodefony/src/context/websocket/websocketUrl.ts
|
|
2
3
|
/**
|
|
3
4
|
* URL d'une requête d'upgrade WebSocket, telle que le pipeline la lit.
|
|
@@ -8,6 +9,10 @@
|
|
|
8
9
|
* un hôte de l'autre — et une route acceptée par l'une serait refusée par
|
|
9
10
|
* l'autre.
|
|
10
11
|
*
|
|
12
|
+
* Une cible en forme absolue (`http://hôte/chemin`, RFC 6455 §4.2.1) donne son
|
|
13
|
+
* hôte, et l'en-tête `Host` est ignoré (RFC 9112 §3.2.2) — la même règle que
|
|
14
|
+
* `HttpRequest.getHost`.
|
|
15
|
+
*
|
|
11
16
|
* @param scheme - `ws` ou `wss`
|
|
12
17
|
* @param host - en-tête `Host` de la requête (`localhost` s'il manque)
|
|
13
18
|
* @param rawUrl - cible brute de la requête (`/` si elle manque)
|
|
@@ -15,6 +20,8 @@
|
|
|
15
20
|
* @throws TypeError si l'hôte ou la cible ne forment pas une URL
|
|
16
21
|
*/
|
|
17
22
|
function parseWebsocketUrl(scheme, host, rawUrl) {
|
|
23
|
+
const absolute = splitAbsoluteForm(rawUrl);
|
|
24
|
+
if (absolute !== null) return new URL(`${scheme}://${absolute.authority}${absolute.target}`);
|
|
18
25
|
return new URL(`${scheme}://${host ?? "localhost"}${rawUrl ?? "/"}`);
|
|
19
26
|
}
|
|
20
27
|
export { parseWebsocketUrl };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TRY_AGAIN_LATER_CLOSE_CODE, decideBackpressure, nextDropBalance } from "nodefony";
|
|
1
2
|
// nodefony/src/context/websocket/wsBackpressure.ts
|
|
2
3
|
/**
|
|
3
4
|
* Relit le seuil + la politique depuis les options du `WebSocketServer`.
|
|
@@ -29,27 +30,26 @@ const readBackpressureOptions = (wss) => {
|
|
|
29
30
|
* « Try Again Later » → le client peut back-off + reconnecter). NE FAIT PAS le `send()`
|
|
30
31
|
* — le caller émet uniquement si le retour vaut `"send"`.
|
|
31
32
|
*
|
|
33
|
+
* La DÉCISION est celle du cœur (`decideBackpressure` / `nextDropBalance`, partagée
|
|
34
|
+
* avec `NodefonySocket`) ; cette fonction n'en porte que l'état (compteurs sur la
|
|
35
|
+
* socket) et le geste serveur (fermeture `1013`).
|
|
36
|
+
*
|
|
32
37
|
* @param ws - la socket destinataire
|
|
33
38
|
* @param max - seuil d'octets (`<= 0` → désactivé)
|
|
34
39
|
* @param policy - action au dépassement
|
|
40
|
+
* @param closeAfterDrops - solde de refus qui ferme (`0` = jamais)
|
|
35
41
|
* @returns `"send"` (émettre) · `"drop"` (sauter cette frame) · `"close"` (socket fermée)
|
|
36
42
|
*/
|
|
37
43
|
const decideSend = (ws, max, policy, closeAfterDrops = 0) => {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
sock._nfDrops = (sock._nfDrops ?? 0) + 1;
|
|
44
|
-
const streak = sock._nfDropStreak = (sock._nfDropStreak ?? 0) + 1;
|
|
45
|
-
if (policy === "close") {
|
|
46
|
-
ws.close(1013, "backpressure");
|
|
47
|
-
return "close";
|
|
48
|
-
}
|
|
49
|
-
if (closeAfterDrops > 0 && streak >= closeAfterDrops) {
|
|
50
|
-
ws.close(1013, "backpressure");
|
|
51
|
-
return "close";
|
|
44
|
+
const balance = ws._nfDropStreak ?? 0;
|
|
45
|
+
const verdict = decideBackpressure(ws.bufferedAmount ?? 0, max, policy, closeAfterDrops, balance);
|
|
46
|
+
if (verdict === "send") {
|
|
47
|
+
if (balance > 0) ws._nfDropStreak = balance - 1;
|
|
48
|
+
return verdict;
|
|
52
49
|
}
|
|
53
|
-
|
|
50
|
+
ws._nfDrops = (ws._nfDrops ?? 0) + 1;
|
|
51
|
+
ws._nfDropStreak = nextDropBalance(verdict, balance);
|
|
52
|
+
if (verdict === "close") ws.close(TRY_AGAIN_LATER_CLOSE_CODE, "backpressure");
|
|
53
|
+
return verdict;
|
|
54
54
|
};
|
|
55
55
|
export { decideSend, readBackpressureOptions };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { decideBackpressure, nextDropBalance } from "nodefony";
|
|
2
|
+
// nodefony/src/context/websocket/wsFrameRate.ts
|
|
3
|
+
/** Durée d'une fenêtre de comptage (ms) : la borne s'exprime en trames PAR SECONDE. */
|
|
4
|
+
const FRAME_RATE_WINDOW_MS = 1e3;
|
|
5
|
+
let rejectedTotal = 0;
|
|
6
|
+
/**
|
|
7
|
+
* Nombre cumulé de trames entrantes écartées par la borne de débit dans ce processus (unité : une trame reçue).
|
|
8
|
+
*
|
|
9
|
+
* Monotone, `0` tant que la borne est désactivée. Relayé par la sonde du hub
|
|
10
|
+
* temps réel sous le nom figé `frameRateRejectedTotal` (ADR-0021 §S9).
|
|
11
|
+
*
|
|
12
|
+
* @returns le cumul depuis le démarrage du processus
|
|
13
|
+
*/
|
|
14
|
+
const frameRateRejectedTotal = () => rejectedTotal;
|
|
15
|
+
/**
|
|
16
|
+
* Lit une borne numérique dans les options du serveur WebSocket d'un contexte.
|
|
17
|
+
*
|
|
18
|
+
* Sans allocation ni conversion de type : un serveur sans `options` (HTTP) ou
|
|
19
|
+
* une clé absente rendent `0`.
|
|
20
|
+
*/
|
|
21
|
+
const readOption = (server, key) => {
|
|
22
|
+
if (typeof server !== "object" || server === null) return 0;
|
|
23
|
+
if (!("options" in server)) return 0;
|
|
24
|
+
const opts = server.options;
|
|
25
|
+
if (typeof opts !== "object" || opts === null || !(key in opts)) return 0;
|
|
26
|
+
const value = Reflect.get(opts, key);
|
|
27
|
+
return typeof value === "number" ? value : 0;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Borne de débit entrante du serveur WebSocket (`maxFramesPerSecond`) ; `0` = désactivée.
|
|
31
|
+
*
|
|
32
|
+
* Appelée à chaque trame : une lecture de propriété, aucune allocation.
|
|
33
|
+
*
|
|
34
|
+
* @param server - le serveur du contexte (`context.server`)
|
|
35
|
+
* @returns trames admises par seconde, `0` si la borne est désactivée
|
|
36
|
+
*/
|
|
37
|
+
const readFrameRateBound = (server) => readOption(server, "maxFramesPerSecond");
|
|
38
|
+
/**
|
|
39
|
+
* Solde de refus qui ferme la connexion (`frameRateCloseAfterRejects`) ; `0` = jamais.
|
|
40
|
+
*
|
|
41
|
+
* @param server - le serveur du contexte (`context.server`)
|
|
42
|
+
* @returns le solde de fermeture
|
|
43
|
+
*/
|
|
44
|
+
const readFrameRateCloseAfter = (server) => readOption(server, "frameRateCloseAfterRejects");
|
|
45
|
+
/**
|
|
46
|
+
* Décide du sort d'une trame reçue, AVANT tout décodage : l'admettre, l'écarter, ou fermer la connexion.
|
|
47
|
+
*
|
|
48
|
+
* Compte des TRAMES, pas des octets (`maxPayload` borne déjà la taille). La
|
|
49
|
+
* décision est la règle unique du cœur (`decideBackpressure`), appliquée au
|
|
50
|
+
* nombre de trames de la fenêtre : écarter au-delà de la borne, fermer quand le
|
|
51
|
+
* solde de refus atteint `closeAfter` — jamais au premier dépassement. Une
|
|
52
|
+
* trame écartée est comptée dans {@link frameRateRejectedTotal}.
|
|
53
|
+
*
|
|
54
|
+
* @param win - la fenêtre de la connexion (mutée)
|
|
55
|
+
* @param now - l'instant de réception (ms)
|
|
56
|
+
* @param bound - trames admises par seconde (`> 0`)
|
|
57
|
+
* @param closeAfter - solde de refus qui ferme (`0` = jamais)
|
|
58
|
+
* @returns `"send"` (admettre), `"drop"` (écarter) ou `"close"` (fermer en `1008`)
|
|
59
|
+
*/
|
|
60
|
+
const admitFrame = (win, now, bound, closeAfter) => {
|
|
61
|
+
if (now - win.start >= 1e3) {
|
|
62
|
+
win.start = now;
|
|
63
|
+
win.count = 0;
|
|
64
|
+
}
|
|
65
|
+
win.count += 1;
|
|
66
|
+
const verdict = decideBackpressure(win.count, bound, "drop", closeAfter, win.balance);
|
|
67
|
+
win.balance = nextDropBalance(verdict, win.balance);
|
|
68
|
+
if (verdict !== "send") rejectedTotal += 1;
|
|
69
|
+
return verdict;
|
|
70
|
+
};
|
|
71
|
+
export { FRAME_RATE_WINDOW_MS, admitFrame, frameRateRejectedTotal, readFrameRateBound, readFrameRateCloseAfter };
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { binaryPayloadLabel } from "nodefony";
|
|
1
2
|
// nodefony/src/context/websocket/wsLogContent.ts
|
|
2
3
|
/**
|
|
3
|
-
* Formatage **pur** (0 état
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Formatage **pur** (0 état) du CONTENU d'un message WebSocket pour le log de trace
|
|
5
|
+
* (Suivi de requête Studio). Séparé de `WebsocketContext` pour être testable aux
|
|
6
|
+
* limites sans serveur.
|
|
6
7
|
*
|
|
7
8
|
* 🔒 Robustesse binaire (cf doc `ws` — `socket.send(data)` accepte
|
|
8
9
|
* `String | Number | Object | Buffer | ArrayBuffer | TypedArray | DataView |
|
|
9
10
|
* Buffer[] | Blob`, et l'event `message` livre `Buffer | ArrayBuffer | Buffer[]`).
|
|
10
|
-
* **Toute** charge binaire est résumée `[binary N B]` — JAMAIS
|
|
11
|
-
* (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme
|
|
12
|
-
* faux).
|
|
11
|
+
* **Toute** charge binaire est résumée `[binary N B]` — JAMAIS vidée octet par
|
|
12
|
+
* octet (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme
|
|
13
|
+
* et faux). Quand la connexion a négocié un codec de frame (CBOR, ADR-0022 §2), la
|
|
14
|
+
* trame est en plus DÉCODÉE pour être lisible : le débogage se fait à l'observation,
|
|
15
|
+
* jamais en changeant le fil.
|
|
13
16
|
*/
|
|
14
17
|
/** Cap de troncature du contenu loggé (octets/chars). Borne ring + JSONL. */
|
|
15
18
|
const WS_LOG_CONTENT_CAP = 4096;
|
|
@@ -40,28 +43,63 @@ function binaryByteLength(data) {
|
|
|
40
43
|
return -1;
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
46
|
+
* Octets imbriqués dans une valeur JSON : leur étiquette, jamais leur vidage.
|
|
47
|
+
*
|
|
48
|
+
* Lit la valeur BRUTE chez son parent (`this[key]`) : `JSON.stringify` appelle
|
|
49
|
+
* `toJSON` avant le replacer, et un `Buffer` — ce que rend un décodage depuis la
|
|
50
|
+
* trame de `ws` — y deviendrait `{"type":"Buffer","data":[…]}`.
|
|
51
|
+
*/
|
|
52
|
+
function binaryAsLabel(key, value) {
|
|
53
|
+
const raw = Reflect.get(this, key);
|
|
54
|
+
if (ArrayBuffer.isView(raw)) return binaryPayloadLabel(raw.byteLength);
|
|
55
|
+
if (raw instanceof ArrayBuffer) return binaryPayloadLabel(raw.byteLength);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/** Sérialisation d'une valeur pour une ligne de journal — jamais de levée. */
|
|
59
|
+
function stringifyForLog(value) {
|
|
60
|
+
let s;
|
|
61
|
+
try {
|
|
62
|
+
s = JSON.stringify(value, binaryAsLabel);
|
|
63
|
+
} catch {
|
|
64
|
+
return String(value);
|
|
65
|
+
}
|
|
66
|
+
return typeof s === "string" ? s : String(value);
|
|
67
|
+
}
|
|
68
|
+
/** Tronque à `cap` caractères, ellipse comprise au-delà. */
|
|
69
|
+
function bound(s, cap) {
|
|
70
|
+
return s.length > cap ? `${s.slice(0, cap)}…` : s;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
43
73
|
* Formate une charge utile WS en chaîne **bornée** et **sûre** pour le log :
|
|
44
74
|
* - `string` → tronquée à `cap` (+ ellipse) ;
|
|
45
|
-
* - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de
|
|
75
|
+
* - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de vidage) ;
|
|
76
|
+
* avec un `codec`, une trame d'un seul tenant est aussi décodée :
|
|
77
|
+
* `[binary N B] <protocole> <frame en JSON>`, octets imbriqués étiquetés ;
|
|
78
|
+
* illisible → `[binary N B] <protocole> illisible` ;
|
|
46
79
|
* - `null`/`undefined` → `""` ;
|
|
47
80
|
* - objet « JSON » → `JSON.stringify` compact tronqué (cycle, `bigint`, valeur
|
|
48
81
|
* non sérialisable → repli `String(...)`).
|
|
49
82
|
*
|
|
50
83
|
* @param data - charge utile (RECEIVE/SEND/BROADCAST).
|
|
51
84
|
* @param cap - longueur max avant troncature (défaut {@link WS_LOG_CONTENT_CAP}).
|
|
85
|
+
* @param codec - codec des trames binaires de la connexion, `null` = octets bruts.
|
|
86
|
+
* @returns la ligne à journaliser, jamais plus longue que `cap` + 1.
|
|
52
87
|
*/
|
|
53
|
-
function formatWsLogContent(data, cap = WS_LOG_CONTENT_CAP) {
|
|
54
|
-
if (typeof data === "string") return data
|
|
88
|
+
function formatWsLogContent(data, cap = WS_LOG_CONTENT_CAP, codec = null) {
|
|
89
|
+
if (typeof data === "string") return bound(data, cap);
|
|
55
90
|
if (data === null || data === void 0) return "";
|
|
56
91
|
const bytes = binaryByteLength(data);
|
|
57
|
-
if (bytes >= 0)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
if (bytes >= 0) {
|
|
93
|
+
const label = binaryPayloadLabel(bytes);
|
|
94
|
+
if (codec === null || !(data instanceof Uint8Array)) return label;
|
|
95
|
+
let frame;
|
|
96
|
+
try {
|
|
97
|
+
frame = codec.decode(data);
|
|
98
|
+
} catch {
|
|
99
|
+
return `${label} ${codec.protocol} illisible`;
|
|
100
|
+
}
|
|
101
|
+
return bound(`${label} ${codec.protocol} ${stringifyForLog(frame)}`, cap);
|
|
63
102
|
}
|
|
64
|
-
|
|
65
|
-
return s.length > cap ? `${s.slice(0, cap)}…` : s;
|
|
103
|
+
return bound(stringifyForLog(data), cap);
|
|
66
104
|
}
|
|
67
105
|
export { WS_LOG_CONTENT_CAP, binaryByteLength, formatWsLogContent };
|