@nodefony/http 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import HttpError from "../src/errors/httpError.js";
|
|
2
|
+
import { buildTrustProxy } from "../src/context/trustProxy.js";
|
|
3
|
+
import { resolveForwarded } from "../src/context/forwarded.js";
|
|
4
|
+
import { MemoryRateLimitStore } from "../src/rateLimit/MemoryRateLimitStore.js";
|
|
5
|
+
import { WsConnectionCounter } from "../src/rateLimit/WsConnectionCounter.js";
|
|
6
|
+
import { compileDomainPatterns, compileTrustedHosts, isDomainAllowed } from "../src/context/domainMatcher.js";
|
|
7
|
+
import Context from "../src/context/Context.js";
|
|
8
|
+
import WebsocketContext from "../src/context/websocket/WebsocketContext.js";
|
|
9
|
+
import HttpContext from "../src/context/http/HttpContext.js";
|
|
10
|
+
import DefaultErrorRenderer from "./error-renderer.js";
|
|
11
|
+
import DefaultRequestLogger from "./request-logger.js";
|
|
12
|
+
import JsonAuditLogger from "./audit-logger.js";
|
|
13
|
+
import PrettyRequestLogger from "./pretty-request-logger.js";
|
|
14
|
+
import { resolveTraceparent } from "./trace.js";
|
|
15
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
|
|
16
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
|
|
17
|
+
import { GcScheduler, Module, RequestContext, Service, injectable, logColor, nodefonyError, writeRuntimeState } from "nodefony";
|
|
18
|
+
import cluster from "node:cluster";
|
|
19
|
+
import Ws from "ws";
|
|
20
|
+
//#region nodefony/service/http-kernel.ts
|
|
21
|
+
const HEALTH_HEADERS = {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
"cache-control": "no-store"
|
|
24
|
+
};
|
|
25
|
+
const HEALTH_BODY_OK = "{\"status\":\"ok\"}";
|
|
26
|
+
const HEALTH_BODY_UNREADY = "{\"status\":\"unready\"}";
|
|
27
|
+
const perfProbe = process.env.NF_PERF_PROBE === "1" ? {
|
|
28
|
+
count: 0,
|
|
29
|
+
enterScopeNs: 0,
|
|
30
|
+
ctxNs: 0,
|
|
31
|
+
leaveScopeNs: 0,
|
|
32
|
+
t0: 0n,
|
|
33
|
+
svcNs: 0,
|
|
34
|
+
ctxBaseNs: 0,
|
|
35
|
+
uploadNs: 0,
|
|
36
|
+
reqResNs: 0,
|
|
37
|
+
svcStartNs: 0,
|
|
38
|
+
svcOptsNs: 0,
|
|
39
|
+
svcLookupsNs: 0,
|
|
40
|
+
svcEventNs: 0,
|
|
41
|
+
reqStartNs: 0,
|
|
42
|
+
reqProxyNs: 0,
|
|
43
|
+
reqUrlNs: 0,
|
|
44
|
+
reqQueryNs: 0,
|
|
45
|
+
reqMetaNs: 0,
|
|
46
|
+
reqAcceptNs: 0
|
|
47
|
+
} : null;
|
|
48
|
+
if (perfProbe) globalThis.__nfPerfProbe = perfProbe;
|
|
49
|
+
const serviceName = "HttpKernel";
|
|
50
|
+
const WS_DEV_LOOPBACK = /* @__PURE__ */ new Set([
|
|
51
|
+
"localhost",
|
|
52
|
+
"127.0.0.1",
|
|
53
|
+
"::1",
|
|
54
|
+
"[::1]"
|
|
55
|
+
]);
|
|
56
|
+
let HttpKernel = class HttpKernel extends Service {
|
|
57
|
+
#statelessIntentVues = null;
|
|
58
|
+
certificates;
|
|
59
|
+
serviceCerticats = null;
|
|
60
|
+
key = "";
|
|
61
|
+
cert = "";
|
|
62
|
+
ca = "";
|
|
63
|
+
serverStatic = null;
|
|
64
|
+
domain = "";
|
|
65
|
+
trustedHosts;
|
|
66
|
+
domainCheck = false;
|
|
67
|
+
regAlias = [];
|
|
68
|
+
module;
|
|
69
|
+
httpsPort;
|
|
70
|
+
httpPort;
|
|
71
|
+
responseTimeout;
|
|
72
|
+
closeTimeOutWs;
|
|
73
|
+
secContentTypeOptions = null;
|
|
74
|
+
secFrameOptions = null;
|
|
75
|
+
secHsts = null;
|
|
76
|
+
sessionService;
|
|
77
|
+
router;
|
|
78
|
+
firewall;
|
|
79
|
+
errorRenderer = new DefaultErrorRenderer();
|
|
80
|
+
requestLogger = new DefaultRequestLogger();
|
|
81
|
+
profiler = null;
|
|
82
|
+
_trustProxyChecker = null;
|
|
83
|
+
_wsOriginPolicy = Object.create(null);
|
|
84
|
+
rateLimiter = null;
|
|
85
|
+
rateLimitGc = null;
|
|
86
|
+
#wsConnCounter = null;
|
|
87
|
+
#healthPaths = null;
|
|
88
|
+
#healthShutdownDelay = 0;
|
|
89
|
+
#terminating = false;
|
|
90
|
+
constructor(module) {
|
|
91
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
92
|
+
this.module = module;
|
|
93
|
+
this.container?.addScope("request");
|
|
94
|
+
this.responseTimeout = {
|
|
95
|
+
http: this.options.http.responseTimeout,
|
|
96
|
+
https: this.options.https.responseTimeout,
|
|
97
|
+
http2: this.options.https.responseTimeout,
|
|
98
|
+
http3: this.options.https.responseTimeout
|
|
99
|
+
};
|
|
100
|
+
this.closeTimeOutWs = {
|
|
101
|
+
ws: this.options.websocket.closeTimeout,
|
|
102
|
+
wss: this.options.websocketSecure.closeTimeout
|
|
103
|
+
};
|
|
104
|
+
this.computeSecurityHeaderCaches();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* (Re)calcule les caches dérivés des en-têtes de sécurité depuis `this.options`
|
|
108
|
+
* — au boot ET à chaque édition live ({@link onConfigChanged}). Lus tels quels
|
|
109
|
+
* dans `onHttpRequest` (0 alloc/concat par requête).
|
|
110
|
+
*/
|
|
111
|
+
computeSecurityHeaderCaches() {
|
|
112
|
+
const sec = this.options.securityHeaders;
|
|
113
|
+
this.secContentTypeOptions = sec?.contentTypeOptions ?? null;
|
|
114
|
+
this.secFrameOptions = sec?.frameOptions ?? null;
|
|
115
|
+
if (sec?.strictTransportSecurity) {
|
|
116
|
+
const hsts = sec.strictTransportSecurity;
|
|
117
|
+
let v = `max-age=${hsts.maxAge}`;
|
|
118
|
+
if (hsts.includeSubDomains) v += "; includeSubDomains";
|
|
119
|
+
if (hsts.preload) v += "; preload";
|
|
120
|
+
this.secHsts = v;
|
|
121
|
+
} else this.secHsts = null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Ce que ce kernel ÉMET réellement comme en-têtes de sécurité transport.
|
|
125
|
+
*
|
|
126
|
+
* Ces trois en-têtes (HSTS, X-Frame-Options, X-Content-Type-Options) sont posés
|
|
127
|
+
* ici, à l'entrée brute, pour couvrir aussi les statiques et les pages d'erreur
|
|
128
|
+
* — pas par `@nodefony/security`, dont les clés homonymes sont inertes. Toute
|
|
129
|
+
* console d'administration doit donc lire CE point, sinon elle affiche une
|
|
130
|
+
* intention de configuration au lieu de l'état appliqué.
|
|
131
|
+
*
|
|
132
|
+
* Lu depuis les caches recalculés au boot ET à chaque édition live
|
|
133
|
+
* ({@link onConfigChanged}) : la valeur rendue suit la config à chaud.
|
|
134
|
+
*
|
|
135
|
+
* @returns les valeurs d'en-tête, `null` quand l'en-tête n'est pas émis.
|
|
136
|
+
*/
|
|
137
|
+
describeTransportSecurityHeaders() {
|
|
138
|
+
return {
|
|
139
|
+
strictTransportSecurity: this.secHsts,
|
|
140
|
+
frameOptions: this.secFrameOptions,
|
|
141
|
+
contentTypeOptions: this.secContentTypeOptions
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Hook appelé par le data plane admin APRÈS une édition LIVE de la config
|
|
146
|
+
* (`PATCH /nodefony/kernel/api/config/http`, dev only). Recompute les valeurs
|
|
147
|
+
* DÉRIVÉES mises en cache (en-têtes sécurité, checker trust-proxy, trustedHosts) :
|
|
148
|
+
* sans lui, l'édition d'un champ `runtimeMutable` porté par un cache serait
|
|
149
|
+
* ignorée. Les champs lus DIRECTEMENT depuis `options` (`headerServer`,
|
|
150
|
+
* `maxBodySize`) n'en ont pas besoin — l'appel reste idempotent et bon marché.
|
|
151
|
+
*/
|
|
152
|
+
onConfigChanged() {
|
|
153
|
+
this.computeSecurityHeaderCaches();
|
|
154
|
+
this._trustProxyChecker = null;
|
|
155
|
+
this._wsOriginPolicy = Object.create(null);
|
|
156
|
+
this.trustedHosts = this.options?.trustedHosts;
|
|
157
|
+
this.regAlias = this.compileAlias();
|
|
158
|
+
this.configureRateLimit();
|
|
159
|
+
this.configureWsConnectionLimit();
|
|
160
|
+
this.configureHealth();
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* (Re)configure le rate-limit général par IP depuis `options.rateLimit` — au
|
|
164
|
+
* boot (`onReady`) ET à chaque édition live ({@link onConfigChanged}). Désarme
|
|
165
|
+
* le GC précédent puis reconstruit compteur + scheduler UNIQUEMENT si
|
|
166
|
+
* `enabled` ; sinon `rateLimiter` reste `null` → 0 coût sur le hot path.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Compteur de rate-limit actif, ou `null` s'il est désactivé — surface de
|
|
170
|
+
* LECTURE pour le data plane admin (introspection des IP suivies). `null` est
|
|
171
|
+
* l'information « fonction désarmée », pas une erreur : l'appelant l'affiche
|
|
172
|
+
* tel quel plutôt que d'inventer un état.
|
|
173
|
+
*/
|
|
174
|
+
get rateLimitStore() {
|
|
175
|
+
return this.rateLimiter;
|
|
176
|
+
}
|
|
177
|
+
configureRateLimit() {
|
|
178
|
+
this.rateLimitGc?.stop();
|
|
179
|
+
this.rateLimitGc = null;
|
|
180
|
+
const cfg = this.options.rateLimit;
|
|
181
|
+
if (!cfg?.enabled) {
|
|
182
|
+
this.rateLimiter = null;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.rateLimiter = new MemoryRateLimitStore({
|
|
186
|
+
windowMs: cfg.windowS * 1e3,
|
|
187
|
+
max: cfg.max,
|
|
188
|
+
maxTracked: cfg.maxTracked
|
|
189
|
+
});
|
|
190
|
+
this.rateLimitGc = new GcScheduler({
|
|
191
|
+
intervalS: cfg.gcIntervalS,
|
|
192
|
+
jitter: cfg.gcJitter !== false,
|
|
193
|
+
run: () => this.rateLimiter?.gc() ?? 0,
|
|
194
|
+
onError: (e) => this.log(e, "WARNING", "RATELIMIT-GC")
|
|
195
|
+
});
|
|
196
|
+
this.rateLimitGc.start();
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* (Re)configure le backstop OPT-IN de connexions WS concurrentes par IP (F6c)
|
|
200
|
+
* depuis `options.wsMaxConnectionsPerIp` — au boot ET à l'édition live. `null` (ou
|
|
201
|
+
* ≤ 0) = désactivé → `#wsConnCounter` reste `null` (0 alloc / 0 tracking, cas
|
|
202
|
+
* cloud-native par défaut : le cap concurrent est délégué à l'ingress). Reconstruit
|
|
203
|
+
* un compteur neuf : les sockets déjà ouvertes décrémentent l'ancienne instance
|
|
204
|
+
* (capturée dans leur closure de fermeture) → pas de dérive sur la nouvelle.
|
|
205
|
+
*/
|
|
206
|
+
configureWsConnectionLimit() {
|
|
207
|
+
const max = this.options.wsMaxConnectionsPerIp;
|
|
208
|
+
this.#wsConnCounter = typeof max === "number" && max > 0 ? new WsConnectionCounter(max) : null;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* (Re)configure les probes de santé `/livez` + `/readyz` depuis
|
|
212
|
+
* `options.health` — au boot (`onReady`) ET à l'édition live. `enabled:false`
|
|
213
|
+
* → `#healthPaths` reste `null` (0 coût sur le hot path).
|
|
214
|
+
*/
|
|
215
|
+
configureHealth() {
|
|
216
|
+
const cfg = this.options.health;
|
|
217
|
+
this.#healthPaths = cfg?.enabled ? {
|
|
218
|
+
liveness: cfg.livenessPath,
|
|
219
|
+
readiness: cfg.readinessPath
|
|
220
|
+
} : null;
|
|
221
|
+
this.#healthShutdownDelay = cfg?.shutdownDelay ?? 0;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Répond une probe de santé — court-circuit TOTAL du pipeline : pas de
|
|
225
|
+
* contexte/scope DI/session/ALS, pas de rate-limit, pas de log par sonde
|
|
226
|
+
* (le kubelet sonde toutes les 2-10 s). Réponses pré-allouées.
|
|
227
|
+
*
|
|
228
|
+
* @param response - réponse serveur brute (HTTP/1 ou H2 compat)
|
|
229
|
+
* @param ok - `true` → 200, `false` → 503 (readiness pendant boot/shutdown)
|
|
230
|
+
*/
|
|
231
|
+
#respondHealth(response, ok) {
|
|
232
|
+
const res = response;
|
|
233
|
+
res.writeHead(ok ? 200 : 503, HEALTH_HEADERS);
|
|
234
|
+
res.end(ok ? HEALTH_BODY_OK : HEALTH_BODY_UNREADY);
|
|
235
|
+
}
|
|
236
|
+
async init() {
|
|
237
|
+
this.applyRequestLoggerFromConfig();
|
|
238
|
+
this.kernel?.prependOnceListener("onReady", () => {
|
|
239
|
+
this.serviceCerticats = this.get("certificates");
|
|
240
|
+
this.serverStatic = this.get("server-static");
|
|
241
|
+
this.domain = this.kernel?.domain;
|
|
242
|
+
this.trustedHosts = this.options?.trustedHosts;
|
|
243
|
+
this.regAlias = this.compileAlias();
|
|
244
|
+
this.sessionService = this.get("sessions");
|
|
245
|
+
this.profiler = this.get("profiler");
|
|
246
|
+
this.configureRateLimit();
|
|
247
|
+
this.configureWsConnectionLimit();
|
|
248
|
+
this.configureHealth();
|
|
249
|
+
});
|
|
250
|
+
this.once("onTerminate", () => {
|
|
251
|
+
this.rateLimitGc?.stop();
|
|
252
|
+
});
|
|
253
|
+
this.kernel?.once("onPostReady", () => {
|
|
254
|
+
this.kernel?.prependOnceListener("onTerminate", async () => {
|
|
255
|
+
this.#terminating = true;
|
|
256
|
+
if (this.#healthShutdownDelay > 0) await new Promise((r) => setTimeout(r, this.#healthShutdownDelay));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
this.kernel?.prependOnceListener("onBoot", () => {
|
|
260
|
+
this.router = this.get("router");
|
|
261
|
+
this.firewall = this.get("firewall");
|
|
262
|
+
});
|
|
263
|
+
return this;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Checker de confiance reverse-proxy, compilé une seule fois depuis
|
|
267
|
+
* `options.trustProxy` (lazy — pas de `BlockList` allouée par requête).
|
|
268
|
+
* Consommé par `HttpRequest`/`HttpContext` pour décider de l'adoption des
|
|
269
|
+
* en-têtes `X-Forwarded-*`.
|
|
270
|
+
*
|
|
271
|
+
* @returns le {@link TrustProxyChecker} partagé.
|
|
272
|
+
*/
|
|
273
|
+
getTrustProxyChecker() {
|
|
274
|
+
if (this._trustProxyChecker === null) this._trustProxyChecker = buildTrustProxy(this.options?.trustProxy);
|
|
275
|
+
return this._trustProxyChecker;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Politique d'Origin WS (B4) compilée paresseusement pour un type de serveur
|
|
279
|
+
* (`websocket` / `websocketSecure`), depuis `options.<type>.allowedOrigins` :
|
|
280
|
+
* - `true` → contrôle désactivé (`disabled`) ;
|
|
281
|
+
* - `false` (défaut) → same-origin seul (`extra` vide) ;
|
|
282
|
+
* - string/liste → Origins cross-origin additionnelles (compilées en RegExp).
|
|
283
|
+
*
|
|
284
|
+
* @param cfgKey - `"websocket"` ou `"websocketSecure"`.
|
|
285
|
+
* @returns la politique mémoïsée (0 alloc après le 1er handshake).
|
|
286
|
+
*/
|
|
287
|
+
getWsOriginPolicy(cfgKey) {
|
|
288
|
+
let policy = this._wsOriginPolicy[cfgKey];
|
|
289
|
+
if (!policy) {
|
|
290
|
+
const raw = this.options[cfgKey]?.allowedOrigins;
|
|
291
|
+
if (raw === true) policy = {
|
|
292
|
+
disabled: true,
|
|
293
|
+
extra: []
|
|
294
|
+
};
|
|
295
|
+
else policy = {
|
|
296
|
+
disabled: false,
|
|
297
|
+
extra: compileDomainPatterns(raw ? raw : [])
|
|
298
|
+
};
|
|
299
|
+
this._wsOriginPolicy[cfgKey] = policy;
|
|
300
|
+
}
|
|
301
|
+
return policy;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* B4 — Validation d'`Origin` au handshake WebSocket (anti-CSWSH, OWASP
|
|
305
|
+
* WSTG-CLNT-10). Les navigateurs n'appliquent PAS CORS aux WebSockets : sans
|
|
306
|
+
* ce contrôle, une page tierce peut ouvrir un WS **authentifié par le cookie de
|
|
307
|
+
* session de la victime** (reprise L1). On exige donc, par défaut, que
|
|
308
|
+
* l'`Origin` du handshake corresponde au `Host` servi (same-origin), avec
|
|
309
|
+
* tolérance loopback en development (Studio Vite cross-port) et allowlist
|
|
310
|
+
* explicite pour les SPA cross-origin (`allowedOrigins`).
|
|
311
|
+
*
|
|
312
|
+
* Une requête SANS `Origin` (client non-navigateur) est acceptée : un attaquant
|
|
313
|
+
* non-navigateur n'a pas besoin de CSWSH, il se connecte directement.
|
|
314
|
+
*
|
|
315
|
+
* @param context - contexte WebSocket au handshake (avant `connect()`).
|
|
316
|
+
* @throws {HttpError} code WS 1008 (Policy Violation) si l'Origin est refusée.
|
|
317
|
+
*/
|
|
318
|
+
checkWebsocketOrigin(context) {
|
|
319
|
+
const cfgKey = context.type === "websocket-secure" ? "websocketSecure" : "websocket";
|
|
320
|
+
const policy = this.getWsOriginPolicy(cfgKey);
|
|
321
|
+
if (policy.disabled) return;
|
|
322
|
+
const raw = context.origin;
|
|
323
|
+
if (!raw) return;
|
|
324
|
+
let originHost = null;
|
|
325
|
+
try {
|
|
326
|
+
originHost = new URL(raw).hostname;
|
|
327
|
+
} catch {
|
|
328
|
+
originHost = null;
|
|
329
|
+
}
|
|
330
|
+
if (originHost) {
|
|
331
|
+
if (originHost === context.domain) return;
|
|
332
|
+
if (this.kernel?.environment === "development" && WS_DEV_LOOPBACK.has(originHost)) return;
|
|
333
|
+
if (policy.extra.length && isDomainAllowed(policy.extra, originHost)) return;
|
|
334
|
+
}
|
|
335
|
+
const error = new HttpError(`WebSocket Origin "${raw}" not allowed (CSWSH protection)`, 1008, context);
|
|
336
|
+
error.type = "origin";
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Reads the **kernel-level** syslog config (`kernel.options.log`) and swaps
|
|
341
|
+
* the request logger accordingly. The decision belongs to syslog (not to
|
|
342
|
+
* the http module) because EVERY request log flows through syslog, and
|
|
343
|
+
* the format choice is an operator concern shared across all transports.
|
|
344
|
+
*
|
|
345
|
+
* Config lue : `kernel.options.log.requestFormat`
|
|
346
|
+
* "auto" → dev=pretty, production=json, autre=default (DEFAULT)
|
|
347
|
+
* "default" → DefaultRequestLogger (legacy verbeux)
|
|
348
|
+
* "pretty" → PrettyRequestLogger (1 ligne colorée, P3.2)
|
|
349
|
+
* "json" → JsonAuditLogger (PDU canonique, P3.1)
|
|
350
|
+
*
|
|
351
|
+
* Programmatic override via `setRequestLogger(custom)` reste possible et
|
|
352
|
+
* gagne toujours sur la config (idempotent — last setter wins).
|
|
353
|
+
*/
|
|
354
|
+
applyRequestLoggerFromConfig() {
|
|
355
|
+
const kernelLog = this.kernel?.options?.log ?? {};
|
|
356
|
+
let format = kernelLog.requestFormat ?? "auto";
|
|
357
|
+
if (format === "auto") {
|
|
358
|
+
const env = this.kernel?.environment;
|
|
359
|
+
format = env === "production" ? "json" : env === "development" ? "pretty" : "default";
|
|
360
|
+
}
|
|
361
|
+
if (format === "pretty") this.requestLogger = new PrettyRequestLogger();
|
|
362
|
+
else if (format === "json") {
|
|
363
|
+
const advanced = kernelLog.requestLogger ?? {};
|
|
364
|
+
const opts = {};
|
|
365
|
+
if (advanced.includeStack !== null && advanced.includeStack !== void 0) opts.includeStack = advanced.includeStack;
|
|
366
|
+
if (typeof advanced.maxCauseDepth === "number") opts.maxCauseDepth = advanced.maxCauseDepth;
|
|
367
|
+
if (typeof advanced.sampleRate === "number") opts.sampleRate = advanced.sampleRate;
|
|
368
|
+
const nominalMode = process.env.NF_BENCH_AUDIT_NOMINAL ?? advanced.nominal ?? "auto";
|
|
369
|
+
opts.nominal = nominalMode === "always" || nominalMode === "auto" && kernelLog.driver !== "null";
|
|
370
|
+
this.requestLogger = new JsonAuditLogger(opts);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async handle(request, response, type) {
|
|
374
|
+
let scope;
|
|
375
|
+
if (perfProbe) {
|
|
376
|
+
const t0 = process.hrtime.bigint();
|
|
377
|
+
scope = this.container?.enterScope("request");
|
|
378
|
+
perfProbe.enterScopeNs += Number(process.hrtime.bigint() - t0);
|
|
379
|
+
perfProbe.count++;
|
|
380
|
+
} else scope = this.container?.enterScope("request");
|
|
381
|
+
if (this.kernel?.debug) this.log(logColor.cyanBgBlue(`${request.url}`), "DEBUG", `${type}`);
|
|
382
|
+
return this.handleHttp(scope, request, response, type);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Arme le contexte pour l'exécution : zone firewall (`context.secure`) et
|
|
386
|
+
* résolveur de route — **sans instancier le controller**.
|
|
387
|
+
*
|
|
388
|
+
* Séparé de {@link handleFrontController} parce que les deux gestes n'ont pas
|
|
389
|
+
* le même moment légitime. Le MATCH doit être précoce : il pose
|
|
390
|
+
* `context.sessionIntent` (depuis `@UseSession`) et `resolver.bypassFirewall`,
|
|
391
|
+
* que le point session unique et le firewall lisent juste après. L'INSTANCE,
|
|
392
|
+
* elle, exécute du code utilisateur (`initialize()`) et résout des dépendances
|
|
393
|
+
* DI : elle n'a rien à faire avant que la requête soit autorisée.
|
|
394
|
+
*
|
|
395
|
+
* @param checkFirewall - calcule `context.secure` (zone protégée) ; laissé à
|
|
396
|
+
* `false` par un appelant qui l'a déjà tranché.
|
|
397
|
+
* @throws HttpError 404 quand aucune route ne matche, ou l'exception portée
|
|
398
|
+
* par le résolveur (405…) — inchangé, et toujours avant le firewall : une
|
|
399
|
+
* route inexistante ne devient pas un 401.
|
|
400
|
+
*/
|
|
401
|
+
async prepareFrontController(context, checkFirewall = true) {
|
|
402
|
+
if (!this.router) throw new Error("kernel HTTP not ready");
|
|
403
|
+
if (this.firewall && checkFirewall) context.secure = this.firewall.isSecure(context);
|
|
404
|
+
let resolver;
|
|
405
|
+
if (context.resolver) resolver = context.resolver;
|
|
406
|
+
else {
|
|
407
|
+
context.phaseStart("resolve");
|
|
408
|
+
try {
|
|
409
|
+
resolver = this.router.resolve(context);
|
|
410
|
+
} finally {
|
|
411
|
+
context.phaseEnd("resolve");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (resolver.resolve && !resolver.exception) {
|
|
415
|
+
context.resolver = resolver;
|
|
416
|
+
return resolver;
|
|
417
|
+
}
|
|
418
|
+
if (resolver.exception) throw resolver.exception;
|
|
419
|
+
throw new HttpError("Not Found", 404, context);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* {@link prepareFrontController} + instanciation immédiate du controller
|
|
423
|
+
* (DI + hook `initialize()`).
|
|
424
|
+
*
|
|
425
|
+
* @remarks Réservé au **WebSocket** : le controller doit exister avant
|
|
426
|
+
* `context.connect()`, puisqu'il porte le protocole négocié et peut encore
|
|
427
|
+
* toucher la réponse du handshake (cookies, en-têtes) — après l'accept, il
|
|
428
|
+
* est trop tard. Le HTTP, lui, arme la route ici et laisse
|
|
429
|
+
* `Resolver.executeAction` créer l'instance une fois la requête autorisée.
|
|
430
|
+
*/
|
|
431
|
+
async handleFrontController(context, checkFirewall = true) {
|
|
432
|
+
return await (await this.prepareFrontController(context, checkFirewall)).newController(context);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Handles errors by setting appropriate response codes and messages.
|
|
436
|
+
*
|
|
437
|
+
* @param error - The error object, which can be of type Error, HttpError, or nodefonyError.
|
|
438
|
+
* @param context - Optional context parameter, which can be of type ContextType.
|
|
439
|
+
* @param extraHeaders - Optional additional headers to be included in the response.
|
|
440
|
+
* @returns A promise that resolves to either HttpContext or WebsocketContext.
|
|
441
|
+
*
|
|
442
|
+
* WebSocket error codes and their meanings:
|
|
443
|
+
* - code >= 1000 && code <= 2999:
|
|
444
|
+
* - 1000: Normal connection closure
|
|
445
|
+
* - 1001: Remote peer is going away
|
|
446
|
+
* - 1002: Protocol error
|
|
447
|
+
* - 1003: Unprocessable input
|
|
448
|
+
* - 1004: Reserved
|
|
449
|
+
* - 1005: Reason not provided
|
|
450
|
+
* - 1006: Abnormal closure, no further detail available
|
|
451
|
+
* - 1007: Invalid data received
|
|
452
|
+
* - 1008: Policy violation
|
|
453
|
+
* - 1009: Message too big
|
|
454
|
+
* - 1010: Extension requested by client is required
|
|
455
|
+
* - 1011: Internal Server Error
|
|
456
|
+
* - 1015: TLS Handshake Failed
|
|
457
|
+
* - code >= 3000 && code <= 3999:
|
|
458
|
+
* Reserved for use by libraries, frameworks, and applications.
|
|
459
|
+
* Should be registered with IANA. Interpretation of these codes is
|
|
460
|
+
* undefined by the WebSocket protocol.
|
|
461
|
+
* - code >= 4000 && code <= 4999:
|
|
462
|
+
* Reserved for private use. Interpretation of these codes is
|
|
463
|
+
* undefined by the WebSocket protocol.
|
|
464
|
+
*/
|
|
465
|
+
/**
|
|
466
|
+
* Override the default error renderer — e.g. to hide stack traces in prod
|
|
467
|
+
* or emit RFC 7807 problem+json. Stateless singleton expected.
|
|
468
|
+
*/
|
|
469
|
+
setErrorRenderer(renderer) {
|
|
470
|
+
this.errorRenderer = renderer;
|
|
471
|
+
}
|
|
472
|
+
getErrorRenderer() {
|
|
473
|
+
return this.errorRenderer;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Override the default per-request logger — JSON access log, NCSA combined,
|
|
477
|
+
* pretty single-line formatter, etc. Stateless singleton expected.
|
|
478
|
+
*/
|
|
479
|
+
setRequestLogger(logger) {
|
|
480
|
+
this.requestLogger = logger;
|
|
481
|
+
}
|
|
482
|
+
getRequestLogger() {
|
|
483
|
+
return this.requestLogger;
|
|
484
|
+
}
|
|
485
|
+
async onError(error, context, _extraHeaders) {
|
|
486
|
+
if (context) context.error = error;
|
|
487
|
+
switch (true) {
|
|
488
|
+
case context instanceof HttpContext: {
|
|
489
|
+
const result = this.errorRenderer.renderHttp(error, context);
|
|
490
|
+
if (error instanceof HttpError || error instanceof nodefonyError) error.code = result.status;
|
|
491
|
+
context.response.setStatusCode(result.status, result.message);
|
|
492
|
+
if (result.headers) context.response.setHeaders(result.headers);
|
|
493
|
+
if (this.kernel?.debug) this.log(error.toString(), "ERROR");
|
|
494
|
+
if (context.finished || context.sended) {
|
|
495
|
+
this.log(error.toString(), "DEBUG", "onError on closed context");
|
|
496
|
+
return context;
|
|
497
|
+
}
|
|
498
|
+
if (!context.response.isHeaderSent()) {
|
|
499
|
+
try {
|
|
500
|
+
await context.render(result.body);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
this.log(e, "CRITIC");
|
|
503
|
+
throw e;
|
|
504
|
+
}
|
|
505
|
+
return context;
|
|
506
|
+
}
|
|
507
|
+
await context.close();
|
|
508
|
+
return context;
|
|
509
|
+
}
|
|
510
|
+
case context instanceof WebsocketContext: try {
|
|
511
|
+
const wsResult = this.errorRenderer.renderWebsocket(error, context);
|
|
512
|
+
if (context.response && context.response.connection) {
|
|
513
|
+
context.close(wsResult.code, wsResult.reason);
|
|
514
|
+
return context;
|
|
515
|
+
}
|
|
516
|
+
if (context.request && !context.rejected) {
|
|
517
|
+
context.reject(wsResult.code, wsResult.reason);
|
|
518
|
+
return context;
|
|
519
|
+
}
|
|
520
|
+
} catch (e) {
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
default: throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
compileAlias() {
|
|
527
|
+
return compileTrustedHosts(this.domain, this.trustedHosts, this.kernel?.environment === "development");
|
|
528
|
+
}
|
|
529
|
+
async onHttpRequest(request, response, type) {
|
|
530
|
+
const headerServer = this.options.headerServer;
|
|
531
|
+
if (headerServer != null && headerServer !== "") response.setHeader("Server", headerServer);
|
|
532
|
+
if (this.secContentTypeOptions !== null) response.setHeader("X-Content-Type-Options", this.secContentTypeOptions);
|
|
533
|
+
if (this.secFrameOptions !== null) response.setHeader("X-Frame-Options", this.secFrameOptions);
|
|
534
|
+
if (this.secHsts !== null && (type === "https" || type === "http2")) response.setHeader("Strict-Transport-Security", this.secHsts);
|
|
535
|
+
if (this.#healthPaths !== null) {
|
|
536
|
+
const url = request.url;
|
|
537
|
+
if (url === this.#healthPaths.liveness) return this.#respondHealth(response, true);
|
|
538
|
+
if (url === this.#healthPaths.readiness) return this.#respondHealth(response, !this.#terminating && this.kernel?.servable === true);
|
|
539
|
+
}
|
|
540
|
+
if (this.rateLimiter !== null) {
|
|
541
|
+
const ip = resolveForwarded(request.headers, request.socket?.remoteAddress, this.getTrustProxyChecker()).clientIp;
|
|
542
|
+
if (ip !== null) {
|
|
543
|
+
const verdict = this.rateLimiter.hit(ip);
|
|
544
|
+
response.setHeader("X-RateLimit-Limit", verdict.limit);
|
|
545
|
+
response.setHeader("X-RateLimit-Remaining", verdict.remaining);
|
|
546
|
+
response.setHeader("X-RateLimit-Reset", Math.ceil(verdict.resetAtMs / 1e3));
|
|
547
|
+
if (verdict.limited) {
|
|
548
|
+
response.setHeader("Retry-After", verdict.retryAfterS);
|
|
549
|
+
const res = response;
|
|
550
|
+
res.writeHead(429);
|
|
551
|
+
res.end();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (this.listenerCount("onServerRequest")) await this.fireAsync("onServerRequest", request, response, type).catch((e) => {
|
|
557
|
+
throw e;
|
|
558
|
+
});
|
|
559
|
+
return this.handle(request, response, type).catch((e) => {
|
|
560
|
+
throw e;
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
async initServers() {
|
|
564
|
+
const servers = [];
|
|
565
|
+
const serverHttp = this.get("server-http");
|
|
566
|
+
if (serverHttp?.active) {
|
|
567
|
+
await serverHttp.createServer();
|
|
568
|
+
servers.push(serverHttp);
|
|
569
|
+
}
|
|
570
|
+
const serverHttps = this.get("server-https");
|
|
571
|
+
if (serverHttps?.active) {
|
|
572
|
+
await serverHttps.createServer();
|
|
573
|
+
servers.push(serverHttps);
|
|
574
|
+
}
|
|
575
|
+
const serverWebsocket = this.get("server-websocket");
|
|
576
|
+
if (serverWebsocket && serverHttp?.active) {
|
|
577
|
+
await serverWebsocket.createServer(serverHttp);
|
|
578
|
+
servers.push(serverWebsocket);
|
|
579
|
+
}
|
|
580
|
+
const serverWebsocketSecure = this.get("server-websocket-secure");
|
|
581
|
+
if (serverWebsocketSecure && serverHttps?.active) {
|
|
582
|
+
await serverWebsocketSecure.createServer(serverHttps);
|
|
583
|
+
servers.push(serverWebsocketSecure);
|
|
584
|
+
}
|
|
585
|
+
this.publishRuntimePorts(serverHttp, serverHttps);
|
|
586
|
+
return servers;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Publie les ports EFFECTIFS du runtime (state file), une fois l'écoute établie.
|
|
590
|
+
*
|
|
591
|
+
* C'est le canal qui rend `servers.portPolicy: "auto"` utilisable : `nodefony
|
|
592
|
+
* status`, `nodefony stop` et la readiness `--detach` sondaient `[5151, 5152]`
|
|
593
|
+
* **en dur**. Dès qu'un port glisse, cette convention ment — ils sonderaient un
|
|
594
|
+
* port que personne n'écoute et conclueraient « serveur down » sur un serveur
|
|
595
|
+
* parfaitement vivant. Ici, la topologie réelle est écrite noir sur blanc.
|
|
596
|
+
*
|
|
597
|
+
* Le port DÉSIRÉ est publié à côté du port obtenu : un outil peut ainsi dire
|
|
598
|
+
* « tu voulais 5151, tu écoutes sur 5153 » sans deviner.
|
|
599
|
+
*
|
|
600
|
+
* **Publié dans TOUS les environnements, production comprise.** Le glissement de
|
|
601
|
+
* port (`auto`) n'est pas la seule façon de sortir de la convention : une app qui
|
|
602
|
+
* déclare son port (PaaS `PORT`, ingress, `servers.http.port`) écoute ailleurs
|
|
603
|
+
* que `[5151, 5152]` **en `strict` aussi**. Sans ce canal, `production --detach
|
|
604
|
+
* --wait` sonderait la convention, ne verrait rien, et group-killerait au bout de
|
|
605
|
+
* son plafond un serveur qui écoutait parfaitement (faux négatif — même famille
|
|
606
|
+
* que « une liste de ports sondés est une CONVENTION, pas la topologie »).
|
|
607
|
+
* L'écriture est best-effort : un `node_modules` en lecture seule (image
|
|
608
|
+
* conteneur) ne fait jamais tomber un serveur qui, lui, écoute très bien.
|
|
609
|
+
*
|
|
610
|
+
* En **cluster**, le pid publié est celui du MASTER, pas du worker qui écrit :
|
|
611
|
+
* les workers sont recyclés (respawn), et un pid mort fait purger le canal par
|
|
612
|
+
* {@link readRuntimeState} — `status`/`stop` redeviendraient aveugles alors que
|
|
613
|
+
* l'app écoute toujours. Le master, lui, est le propriétaire du runtime.
|
|
614
|
+
*/
|
|
615
|
+
publishRuntimePorts(serverHttp, serverHttps) {
|
|
616
|
+
const ports = [];
|
|
617
|
+
const desired = [];
|
|
618
|
+
if (serverHttp?.active && serverHttp.port > 0) {
|
|
619
|
+
ports.push(serverHttp.port);
|
|
620
|
+
const d = this.kernel?.options.servers?.http;
|
|
621
|
+
if (d && d.port) desired.push(d.port);
|
|
622
|
+
}
|
|
623
|
+
if (serverHttps?.active && serverHttps.port > 0) {
|
|
624
|
+
ports.push(serverHttps.port);
|
|
625
|
+
const d = this.kernel?.options.servers?.https;
|
|
626
|
+
if (d && d.port) desired.push(d.port);
|
|
627
|
+
}
|
|
628
|
+
if (ports.length === 0) return;
|
|
629
|
+
writeRuntimeState(process.cwd(), {
|
|
630
|
+
pid: cluster.isWorker ? process.ppid ?? process.pid : process.pid,
|
|
631
|
+
ports,
|
|
632
|
+
desiredPorts: desired.length > 0 ? desired : void 0
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Point d'activation UNIQUE de session — symétrique HTTP **et** WS. Ouvre une
|
|
637
|
+
* session si, et seulement si, la route la déclare (`context.sessionIntent`,
|
|
638
|
+
* posé par le Resolver depuis `@UseSession` / un paramètre `@Session`) **ou**
|
|
639
|
+
* si un cookie de session entrant existe déjà (reprise — L1). Sinon : aucune
|
|
640
|
+
* session (lazy). Remplace l'ancien `sessionAutoStart` global « démarre sur
|
|
641
|
+
* toutes les routes » (le moteur du ×23).
|
|
642
|
+
*
|
|
643
|
+
* @param context - contexte HTTP/HTTP2/WS courant.
|
|
644
|
+
* @returns la session active, ou `null` si la requête n'en requiert aucune.
|
|
645
|
+
*/
|
|
646
|
+
async startSession(context) {
|
|
647
|
+
if (!this.sessionService) return null;
|
|
648
|
+
const intent = context.sessionIntent;
|
|
649
|
+
if (context.security?.stateless === true) {
|
|
650
|
+
if (intent) {
|
|
651
|
+
const name = context.security.name;
|
|
652
|
+
if (this.#statelessIntentVues === null) this.#statelessIntentVues = /* @__PURE__ */ new Set();
|
|
653
|
+
if (!this.#statelessIntentVues.has(name)) {
|
|
654
|
+
this.#statelessIntentVues.add(name);
|
|
655
|
+
this.log(`Zone "${name}" is stateless: the session requested by this route is IGNORED (no session opened, no cookie sent). Drop \`stateless: true\` on the area, or stop requiring a session on its routes.`, "WARNING", "SESSION");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
if (!intent && !context.hasSession()) return null;
|
|
661
|
+
return await this.sessionService.start(context, intent?.readOnly);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Teardown post-réponse — fire-and-forget depuis le `once("close")` posé par
|
|
665
|
+
* `createHttpContext` (un seul fire possible : once auto-détaché). Loggue la
|
|
666
|
+
* requête, draine les hooks afterResponse/onFinish, libère le scope DI.
|
|
667
|
+
*/
|
|
668
|
+
async teardownHttp(context, scope) {
|
|
669
|
+
if (context.finished) return;
|
|
670
|
+
try {
|
|
671
|
+
if (context.waitAsync && !context.sended && this.kernel?.environment === "development") this.log(`Action "${context.resolver?.route?.name ?? context.url}" returned a non-renderable value (void/null/class instance) and never sent a response. Use 'return <object|string|number|boolean|Buffer>' (auto-JSON) or send/stream manually.`, "WARNING");
|
|
672
|
+
context.logRequest();
|
|
673
|
+
context.logPhasesVerbose();
|
|
674
|
+
this.profiler?.collect(context);
|
|
675
|
+
await context._runAfterResponse();
|
|
676
|
+
if (context.listenerCount("onFinish")) await context.fireAsync("onFinish", context);
|
|
677
|
+
context.finished = true;
|
|
678
|
+
if (perfProbe) {
|
|
679
|
+
const t0 = process.hrtime.bigint();
|
|
680
|
+
this.container?.leaveScope(scope);
|
|
681
|
+
perfProbe.leaveScopeNs += Number(process.hrtime.bigint() - t0);
|
|
682
|
+
} else this.container?.leaveScope(scope);
|
|
683
|
+
context.clean();
|
|
684
|
+
} catch (e) {
|
|
685
|
+
this.log(e, "ERROR", "TEARDOWN");
|
|
686
|
+
if (!context.finished) {
|
|
687
|
+
context.finished = true;
|
|
688
|
+
this.container?.leaveScope(scope);
|
|
689
|
+
context.clean();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
createHttpContext(scope, request, response, type) {
|
|
694
|
+
try {
|
|
695
|
+
let context;
|
|
696
|
+
if (perfProbe) {
|
|
697
|
+
const t0 = process.hrtime.bigint();
|
|
698
|
+
perfProbe.t0 = t0;
|
|
699
|
+
context = new HttpContext(scope, request, response, type);
|
|
700
|
+
perfProbe.ctxNs += Number(process.hrtime.bigint() - t0);
|
|
701
|
+
perfProbe.t0 = 0n;
|
|
702
|
+
} else context = new HttpContext(scope, request, response, type);
|
|
703
|
+
response.once("close", () => {
|
|
704
|
+
if (!response.writableEnded) {
|
|
705
|
+
context._abortIfPending("Connection closed before response finished");
|
|
706
|
+
if (!context.sended) context.response.statusCode = 499;
|
|
707
|
+
}
|
|
708
|
+
this.teardownHttp(context, scope);
|
|
709
|
+
});
|
|
710
|
+
return context;
|
|
711
|
+
} catch (e) {
|
|
712
|
+
this.log(e, "ERROR");
|
|
713
|
+
throw e;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async handleHttp(scope, request, response, type) {
|
|
717
|
+
let context = null;
|
|
718
|
+
try {
|
|
719
|
+
context = this.createHttpContext(scope, request, response, type);
|
|
720
|
+
if (this.listenerCount("onCreateContext")) await this.fireAsync("onCreateContext", context);
|
|
721
|
+
context.traceparent = resolveTraceparent(request.headers?.traceparent);
|
|
722
|
+
const profilerQueries = this.profiler ? [] : null;
|
|
723
|
+
context.profilerQueries = profilerQueries;
|
|
724
|
+
context.profiling = profilerQueries !== null;
|
|
725
|
+
return await RequestContext.run({
|
|
726
|
+
requestId: context.requestId,
|
|
727
|
+
scheme: context.scheme,
|
|
728
|
+
traceparent: context.traceparent,
|
|
729
|
+
queries: profilerQueries ?? void 0,
|
|
730
|
+
context
|
|
731
|
+
}, async () => {
|
|
732
|
+
if (this.firewall?.handleCors(context) === 204) {
|
|
733
|
+
context.response.writeHead(204);
|
|
734
|
+
context.response.end();
|
|
735
|
+
return context;
|
|
736
|
+
}
|
|
737
|
+
context.phaseStart("resolve");
|
|
738
|
+
context.resolver = this.router ? this.router.resolve(context) : null;
|
|
739
|
+
context.phaseEnd("resolve");
|
|
740
|
+
this.firewall?.applySecurityHeaders(context);
|
|
741
|
+
if (this.serverStatic && context.resolver?.resolve !== true && !context.resolver?.exception && (this.kernel?.options.servers.statics || this.kernel?.options.statics || this.serverStatic.hasMounts())) {
|
|
742
|
+
response.removeHeader("Content-Type");
|
|
743
|
+
await this.serverStatic.handle(request, response).catch(() => void 0);
|
|
744
|
+
}
|
|
745
|
+
const streamBody = context.resolver?.resolve === true && context.resolver.route?.bodyStream === true;
|
|
746
|
+
context.phaseStart("parse");
|
|
747
|
+
try {
|
|
748
|
+
if (!streamBody) await context.request.initialize();
|
|
749
|
+
} finally {
|
|
750
|
+
context.phaseEnd("parse");
|
|
751
|
+
}
|
|
752
|
+
const ctx = await this.onRequestEnd(context);
|
|
753
|
+
if (ctx instanceof Context) {
|
|
754
|
+
ctx.phaseStart("action");
|
|
755
|
+
try {
|
|
756
|
+
return await ctx.handle();
|
|
757
|
+
} finally {
|
|
758
|
+
ctx.phaseEnd("action");
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return context;
|
|
762
|
+
});
|
|
763
|
+
} catch (e) {
|
|
764
|
+
return await this.onError(e, context);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async onRequestEnd(context, error) {
|
|
768
|
+
if (!context) throw new nodefonyError("Bad context", 500);
|
|
769
|
+
if (error) throw error;
|
|
770
|
+
if (this.options[context.scheme].headers) context.response.setHeaders(this.options[context.scheme].headers);
|
|
771
|
+
if (this.kernel?.options.domainCheck) this.checkValidDomain(context);
|
|
772
|
+
if (this.listenerCount("beforeResolve")) await this.fireAsync("beforeResolve", context);
|
|
773
|
+
await this.prepareFrontController(context);
|
|
774
|
+
this.firewall?.enforceCsrf(context);
|
|
775
|
+
await this.startSession(context);
|
|
776
|
+
if (context.secure || context.isControlledAccess) {
|
|
777
|
+
context.phaseStart("firewall");
|
|
778
|
+
try {
|
|
779
|
+
try {
|
|
780
|
+
await this.firewall?.handleSecurity(context);
|
|
781
|
+
if (this.listenerCount("afterAuth")) await this.fireAsync("afterAuth", context);
|
|
782
|
+
} catch (authError) {
|
|
783
|
+
await this.fireAsync("onAuthFailure", context, authError).catch((e) => this.log(e, "ERROR", "onAuthFailure"));
|
|
784
|
+
throw authError;
|
|
785
|
+
}
|
|
786
|
+
} finally {
|
|
787
|
+
context.phaseEnd("firewall");
|
|
788
|
+
}
|
|
789
|
+
return context;
|
|
790
|
+
}
|
|
791
|
+
return context;
|
|
792
|
+
}
|
|
793
|
+
createWebsocketContext(scope, req, ws, type) {
|
|
794
|
+
const context = new WebsocketContext(scope, req, ws, type);
|
|
795
|
+
context.once("onFinish", async (wscontext) => {
|
|
796
|
+
if (!context) return;
|
|
797
|
+
if (context.finished) return;
|
|
798
|
+
context._abortIfPending("WebSocket closed");
|
|
799
|
+
await context._runAfterResponse();
|
|
800
|
+
if (context.session && !context.session.saved) try {
|
|
801
|
+
await context.saveSession();
|
|
802
|
+
} catch (e) {
|
|
803
|
+
this.log(e, "ERROR", "WS onFinish saveSession");
|
|
804
|
+
}
|
|
805
|
+
this.container?.leaveScope(wscontext.container);
|
|
806
|
+
context.clean();
|
|
807
|
+
context.finished = true;
|
|
808
|
+
});
|
|
809
|
+
return context;
|
|
810
|
+
}
|
|
811
|
+
async onWebsocketRequest(ws, req, type) {
|
|
812
|
+
const wsConnCounter = this.#wsConnCounter;
|
|
813
|
+
if (this.rateLimiter !== null || wsConnCounter !== null) {
|
|
814
|
+
const ip = resolveForwarded(req.headers, req.socket?.remoteAddress, this.getTrustProxyChecker()).clientIp;
|
|
815
|
+
if (ip !== null) {
|
|
816
|
+
if (this.rateLimiter !== null && this.rateLimiter.hit(ip).limited) {
|
|
817
|
+
if (ws.readyState === Ws.OPEN) ws.close(1013, "rate limit");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (wsConnCounter !== null) {
|
|
821
|
+
if (!wsConnCounter.tryAcquire(ip)) {
|
|
822
|
+
if (ws.readyState === Ws.OPEN) ws.close(1013, "too many connections");
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
ws.once("close", () => wsConnCounter.release(ip));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
await this.fireAsync("onServerRequest", req, null, type).catch((e) => {
|
|
830
|
+
throw e;
|
|
831
|
+
});
|
|
832
|
+
const scope = this.container?.enterScope("request");
|
|
833
|
+
return this.handleWebsocket(scope, ws, req, type).catch((e) => {
|
|
834
|
+
throw e;
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
async handleWebsocket(scope, ws, req, type) {
|
|
838
|
+
let context = null;
|
|
839
|
+
let error = null;
|
|
840
|
+
try {
|
|
841
|
+
context = this.createWebsocketContext(scope, req, ws, type);
|
|
842
|
+
} catch (e) {
|
|
843
|
+
error = e;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
if (context) {
|
|
847
|
+
context.traceparent = resolveTraceparent(req.headers?.traceparent);
|
|
848
|
+
context.profiling = this.profiler !== null;
|
|
849
|
+
}
|
|
850
|
+
const wsRunId = context?.requestId ?? "ws-no-ctx";
|
|
851
|
+
const wsScheme = context?.scheme ?? "ws";
|
|
852
|
+
const wsTrace = context?.traceparent ?? null;
|
|
853
|
+
return await RequestContext.run({
|
|
854
|
+
requestId: wsRunId,
|
|
855
|
+
scheme: wsScheme,
|
|
856
|
+
...wsTrace ? { traceparent: wsTrace } : {},
|
|
857
|
+
context
|
|
858
|
+
}, async () => {
|
|
859
|
+
await this.onConnect(context, error);
|
|
860
|
+
if (this.firewall && (context?.secure || context?.isControlledAccess)) {
|
|
861
|
+
context.phaseStart("firewall");
|
|
862
|
+
try {
|
|
863
|
+
try {
|
|
864
|
+
await this.firewall.handleSecurity(context);
|
|
865
|
+
await this.fireAsync("afterAuth", context);
|
|
866
|
+
} catch (authError) {
|
|
867
|
+
await this.fireAsync("onAuthFailure", context, authError).catch((e) => this.log(e, "ERROR", "onAuthFailure"));
|
|
868
|
+
throw authError;
|
|
869
|
+
}
|
|
870
|
+
} finally {
|
|
871
|
+
context.phaseEnd("firewall");
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (context) {
|
|
875
|
+
context.phaseStart("action");
|
|
876
|
+
try {
|
|
877
|
+
return await context.handle();
|
|
878
|
+
} finally {
|
|
879
|
+
context.phaseEnd("action");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
} catch (e) {
|
|
884
|
+
try {
|
|
885
|
+
await this.onError(e, context);
|
|
886
|
+
} catch (errorHandlingError) {
|
|
887
|
+
this.releaseOrphanWsScope(scope, context);
|
|
888
|
+
throw errorHandlingError;
|
|
889
|
+
}
|
|
890
|
+
this.releaseOrphanWsScope(scope, context);
|
|
891
|
+
throw e;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
releaseOrphanWsScope(scope, context) {
|
|
895
|
+
if (!context) {
|
|
896
|
+
this.container?.leaveScope(scope);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (context.teardownWired || context.finished) return;
|
|
900
|
+
context.finished = true;
|
|
901
|
+
this.container?.leaveScope(scope);
|
|
902
|
+
context.clean();
|
|
903
|
+
}
|
|
904
|
+
async onConnect(context, error = null) {
|
|
905
|
+
if (error) throw error;
|
|
906
|
+
if (!context) throw new nodefonyError("Bad context", 500);
|
|
907
|
+
if (this.domainCheck) this.checkValidDomain(context);
|
|
908
|
+
this.checkWebsocketOrigin(context);
|
|
909
|
+
await this.fireAsync("beforeResolve", context);
|
|
910
|
+
try {
|
|
911
|
+
await this.handleFrontController(context);
|
|
912
|
+
} catch (e) {
|
|
913
|
+
context.logRequest(e);
|
|
914
|
+
throw e;
|
|
915
|
+
}
|
|
916
|
+
if (!context.sessionStarting) await this.startSession(context);
|
|
917
|
+
return await context.connect();
|
|
918
|
+
}
|
|
919
|
+
checkValidDomain(context) {
|
|
920
|
+
if (context.validDomain) return 200;
|
|
921
|
+
const error = `DOMAIN Misdirected Request : ${context.domain}`;
|
|
922
|
+
throw new HttpError(error, 421);
|
|
923
|
+
}
|
|
924
|
+
isValidDomain(context) {
|
|
925
|
+
return this.isTrustedHostname(context.domain);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Ce nom d'hôte franchit-il la barrière `trustedHosts` ? Même liste compilée
|
|
929
|
+
* que `isValidDomain`, mais interrogeable **sans contexte** — un module qui
|
|
930
|
+
* doit décider si un `Host` reçu est légitime (ex. `@nodefony/frontend`, qui
|
|
931
|
+
* en dérive l'origine des assets Vite) appelle CETTE méthode plutôt que de
|
|
932
|
+
* recompiler les motifs de son côté : deux copies divergeraient en silence.
|
|
933
|
+
*
|
|
934
|
+
* Résolution par NOM (`container.get("HttpKernel")`) chez l'appelant — aucun
|
|
935
|
+
* import de `@nodefony/http`, donc aucun cycle.
|
|
936
|
+
*
|
|
937
|
+
* @param hostname - nom d'hôte SANS port (`Context.domain`).
|
|
938
|
+
* @returns `true` si un motif de `trustedHosts` (ou le domaine canonique)
|
|
939
|
+
* couvre ce nom.
|
|
940
|
+
*/
|
|
941
|
+
isTrustedHostname(hostname) {
|
|
942
|
+
return isDomainAllowed(this.regAlias, hostname);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
HttpKernel = __decorate([injectable(), __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module])], HttpKernel);
|
|
946
|
+
var http_kernel_default = HttpKernel;
|
|
947
|
+
//#endregion
|
|
948
|
+
export { http_kernel_default as default };
|