@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatage **pur** (0 état, 0 dépendance) du CONTENU d'un message WebSocket pour
|
|
3
|
+
* le log de trace (Suivi de requête Studio). Séparé de `WebsocketContext` pour
|
|
4
|
+
* être testable aux limites sans serveur.
|
|
5
|
+
*
|
|
6
|
+
* 🔒 Robustesse binaire (cf doc `ws` — `socket.send(data)` accepte
|
|
7
|
+
* `String | Number | Object | Buffer | ArrayBuffer | TypedArray | DataView |
|
|
8
|
+
* Buffer[] | Blob`, et l'event `message` livre `Buffer | ArrayBuffer | Buffer[]`).
|
|
9
|
+
* **Toute** charge binaire est résumée `[binary N B]` — JAMAIS sérialisée
|
|
10
|
+
* (`JSON.stringify(new Uint8Array(...))` produirait `{"0":..,"1":..}`, énorme et
|
|
11
|
+
* faux). Seules string et objets « JSON » sont rendus en texte (borné).
|
|
12
|
+
*/
|
|
13
|
+
/** Cap de troncature du contenu loggé (octets/chars). Borne ring + JSONL. */
|
|
14
|
+
export declare const WS_LOG_CONTENT_CAP = 4096;
|
|
15
|
+
/**
|
|
16
|
+
* Taille en octets d'une charge **binaire** reconnue, ou `-1` si la valeur n'est
|
|
17
|
+
* pas binaire (→ à traiter en JSON/texte). Couvre tous les types binaires que
|
|
18
|
+
* `ws` accepte/livre : `Buffer`, `ArrayBuffer`, vues (`TypedArray`/`DataView`),
|
|
19
|
+
* `Blob` (Node ≥ 18), et `Buffer[]` (fragments — binaire ssi TOUS ses éléments
|
|
20
|
+
* le sont, sinon c'est un tableau JSON ordinaire).
|
|
21
|
+
*
|
|
22
|
+
* @param data - valeur à mesurer.
|
|
23
|
+
* @returns nombre d'octets, ou `-1` si non binaire.
|
|
24
|
+
*/
|
|
25
|
+
export declare function binaryByteLength(data: unknown): number;
|
|
26
|
+
/**
|
|
27
|
+
* Formate une charge utile WS en chaîne **bornée** et **sûre** pour le log :
|
|
28
|
+
* - `string` → tronquée à `cap` (+ ellipse) ;
|
|
29
|
+
* - binaire (cf {@link binaryByteLength}) → `[binary N B]` (jamais de dump) ;
|
|
30
|
+
* - `null`/`undefined` → `""` ;
|
|
31
|
+
* - objet « JSON » → `JSON.stringify` compact tronqué (cycle, `bigint`, valeur
|
|
32
|
+
* non sérialisable → repli `String(...)`).
|
|
33
|
+
*
|
|
34
|
+
* @param data - charge utile (RECEIVE/SEND/BROADCAST).
|
|
35
|
+
* @param cap - longueur max avant troncature (défaut {@link WS_LOG_CONTENT_CAP}).
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatWsLogContent(data: unknown, cap?: number): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { StringValue } from "ms";
|
|
2
|
+
import { ContextType } from "../../service/http-kernel.js";
|
|
3
|
+
import type { ICookie as ICookieInterface, ICookieOptions, IWsCookie, PriorityType, SameSiteType } from "../../interfaces/ICookie.js";
|
|
4
|
+
declare module "http" {
|
|
5
|
+
interface IncomingMessage {
|
|
6
|
+
cookies: Record<string, Cookie>;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
declare module "http2" {
|
|
10
|
+
interface Http2ServerRequest {
|
|
11
|
+
cookies: Record<string, Cookie>;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Options d'un cookie — alias du contrat public {@link ICookieOptions}.
|
|
16
|
+
*
|
|
17
|
+
* Conservé sous ce nom parce que `session.ts` et l'historique du module
|
|
18
|
+
* l'importent ainsi ; ce n'est PAS une seconde définition. Un consommateur
|
|
19
|
+
* externe nomme le type par `ICookieOptions`, exporté au barrel.
|
|
20
|
+
*/
|
|
21
|
+
export type CookieOptionsType = ICookieOptions;
|
|
22
|
+
declare function parser(strToParse: string): import("cookie").Cookies;
|
|
23
|
+
declare function cookiesParser(context: ContextType): void;
|
|
24
|
+
declare class Cookie implements ICookieInterface {
|
|
25
|
+
options: CookieOptionsType;
|
|
26
|
+
name: string;
|
|
27
|
+
signed?: boolean;
|
|
28
|
+
value: unknown;
|
|
29
|
+
originalMaxAge?: number;
|
|
30
|
+
expires?: Date;
|
|
31
|
+
maxAge?: number;
|
|
32
|
+
path?: string;
|
|
33
|
+
domain: string | undefined;
|
|
34
|
+
httpOnly?: boolean;
|
|
35
|
+
secure?: boolean;
|
|
36
|
+
sameSite?: SameSiteType;
|
|
37
|
+
priority?: string;
|
|
38
|
+
constructor(cookies: Cookie);
|
|
39
|
+
constructor(name: string, value: unknown, options?: CookieOptionsType);
|
|
40
|
+
clearCookie(): void;
|
|
41
|
+
setValue(value: unknown): unknown;
|
|
42
|
+
setSecure(val: boolean | undefined): boolean;
|
|
43
|
+
setDomain(): string | undefined;
|
|
44
|
+
setHttpOnly(val: boolean | undefined): boolean;
|
|
45
|
+
setPath(val: string | undefined): string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Normalise l'attribut `SameSite` vers une valeur canonique RFC 6265bis.
|
|
48
|
+
*
|
|
49
|
+
* Fallback **`Lax`** (jamais `None`) : `None` désactive la protection CSRF et
|
|
50
|
+
* impose `Secure` — ce n'est jamais un défaut sûr. Toute entrée inconnue
|
|
51
|
+
* (ancien `boolean`, casse libre) retombe sur `Lax` (fail-safe).
|
|
52
|
+
*
|
|
53
|
+
* @param val - valeur souhaitée (`Strict` / `Lax` / `None`, casse libre).
|
|
54
|
+
* @returns la forme canonique title-case.
|
|
55
|
+
*/
|
|
56
|
+
setSameSite(val: SameSiteType | undefined): SameSiteType;
|
|
57
|
+
setExpires(date: Date | string | number | undefined): Date | undefined;
|
|
58
|
+
setOriginalMaxAge(ms: number | StringValue): number;
|
|
59
|
+
setPriority(val: PriorityType): PriorityType;
|
|
60
|
+
getMaxAge(): number | undefined;
|
|
61
|
+
toString(): string;
|
|
62
|
+
/**
|
|
63
|
+
* Signe une valeur de cookie : `HMAC-SHA256(value)` clé = `secret`, encodé en
|
|
64
|
+
* base64url (sûr en cookie, pas de `=`/`+`/`/`). Retourne `value.signature`
|
|
65
|
+
* → la valeur d'origine est PRÉSERVÉE (récupérable via {@link unsign}).
|
|
66
|
+
*
|
|
67
|
+
* @param val - valeur en clair à signer.
|
|
68
|
+
* @param secret - clé HMAC (le secret, JAMAIS la valeur).
|
|
69
|
+
* @returns `value.base64url(hmac)`.
|
|
70
|
+
* @throws {TypeError} si `val` n'est pas une string ou `secret` est vide.
|
|
71
|
+
*/
|
|
72
|
+
sign(val: unknown, secret: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Vérifie et déballe une valeur signée par {@link sign} (tolère le préfixe
|
|
75
|
+
* `s:`). Comparaison **timing-safe** (`crypto.timingSafeEqual`).
|
|
76
|
+
*
|
|
77
|
+
* @param val - valeur signée (`value.signature`, éventuellement préfixée `s:`).
|
|
78
|
+
* Défaut : `this.value`.
|
|
79
|
+
* @param secret - clé HMAC. Défaut : `this.options.secret`.
|
|
80
|
+
* @returns la valeur en clair si la signature est valide, sinon `false`.
|
|
81
|
+
* @throws {TypeError} si les types sont invalides ou le secret est vide.
|
|
82
|
+
*/
|
|
83
|
+
unsign(val?: string, secret?: string): string | false;
|
|
84
|
+
serialize(): string;
|
|
85
|
+
serializeWebSocket(): IWsCookie;
|
|
86
|
+
}
|
|
87
|
+
export default Cookie;
|
|
88
|
+
export { cookiesParser, parser };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { nodefonyError as NodefonyError } from "nodefony";
|
|
2
|
+
import { ContextType } from "../../service/http-kernel.js";
|
|
3
|
+
import { HttpRequestType, HttpRsponseType } from "../context/http/HttpContext.js";
|
|
4
|
+
declare class HttpError extends NodefonyError {
|
|
5
|
+
context?: ContextType;
|
|
6
|
+
response?: HttpRsponseType;
|
|
7
|
+
request?: HttpRequestType;
|
|
8
|
+
url?: string;
|
|
9
|
+
controller?: string;
|
|
10
|
+
action?: string;
|
|
11
|
+
jsonResponse?: string;
|
|
12
|
+
constructor(message?: unknown, code?: number, context?: ContextType);
|
|
13
|
+
toString(): string;
|
|
14
|
+
}
|
|
15
|
+
export default HttpError;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { IProfilerQuery } from "nodefony";
|
|
2
|
+
import type { ISecurityTrace, PhaseName, PhaseTiming } from "../../interfaces/IContext.js";
|
|
3
|
+
/**
|
|
4
|
+
* Zone firewall capturée sur le contexte (`SecuredArea`, lue en structurel :
|
|
5
|
+
* `@nodefony/http` ne peut pas importer `@nodefony/security`).
|
|
6
|
+
*/
|
|
7
|
+
export interface ProfiledArea {
|
|
8
|
+
name?: string;
|
|
9
|
+
security?: boolean;
|
|
10
|
+
mode?: string;
|
|
11
|
+
authenticators?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/** Ce que le Profiler lit d'un Resolver (route/controller/action). */
|
|
14
|
+
export interface ProfiledResolver {
|
|
15
|
+
route?: {
|
|
16
|
+
name?: string;
|
|
17
|
+
} | null;
|
|
18
|
+
controller?: {
|
|
19
|
+
name?: string;
|
|
20
|
+
} | null;
|
|
21
|
+
actionName?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Descripteur d'ouverture d'une invocation (ce que le pont connaît d'entrée). */
|
|
24
|
+
export interface FrameProfileInit {
|
|
25
|
+
/** Identifiant du profil — `<requestId de la connexion>.<n° de frame>`. */
|
|
26
|
+
requestId: string;
|
|
27
|
+
/** Type de transport du contexte porteur (`websocket` / `websocket-secure`). */
|
|
28
|
+
type: string;
|
|
29
|
+
scheme: string;
|
|
30
|
+
/** Méthode LOGIQUE de l'invocation (`GET`, `POST`…), pas le transport. */
|
|
31
|
+
method: string;
|
|
32
|
+
/** Chemin invoqué par la frame (avec sa query), pas l'URL de la connexion. */
|
|
33
|
+
url: string;
|
|
34
|
+
remoteAddress: string | null;
|
|
35
|
+
traceparent: string | null;
|
|
36
|
+
/** Zone firewall de la connexion (l'identité de la frame en découle). */
|
|
37
|
+
security: ProfiledArea | null;
|
|
38
|
+
/** Décision du firewall au handshake — l'identité que la frame rejoue. */
|
|
39
|
+
securityTrace: ISecurityTrace | null;
|
|
40
|
+
/** Mesurer les phases ? (`timing.enabled` du contexte porteur.) */
|
|
41
|
+
timing: boolean;
|
|
42
|
+
/** Collecter le SQL ? (profiler dev présent → buffer ORM alloué.) */
|
|
43
|
+
queries: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Profil d'**une invocation** du pont RPC (une frame `api.request`), et non de
|
|
47
|
+
* la connexion qui la porte.
|
|
48
|
+
*
|
|
49
|
+
* Pourquoi un objet séparé du `Context` : un `WebsocketContext` vit pour toute
|
|
50
|
+
* la **connexion**, alors qu'une socket peut porter des centaines d'invocations,
|
|
51
|
+
* concurrentes de surcroît. Empiler leurs phases sur le contexte produirait une
|
|
52
|
+
* timeline **cumulative** (donc fausse) et ferait croître `Context.phases` sans
|
|
53
|
+
* borne. Le profil naît et meurt donc avec la frame ; il voyage dans l'ALS
|
|
54
|
+
* (`RequestContext.payload.invocation`), seul canal déjà per-invocation traversé
|
|
55
|
+
* par le Resolver, le controller et les adapters ORM.
|
|
56
|
+
*
|
|
57
|
+
* Il satisfait **structurellement** ce que `Profiler.collect()` lit d'un
|
|
58
|
+
* contexte : aucune connaissance du transport n'est requise côté Profiler, un
|
|
59
|
+
* profil de frame s'y collecte comme une requête HTTP (`kind: "ws"`).
|
|
60
|
+
*
|
|
61
|
+
* Coût : **rien n'est alloué** quand le profiler dev est absent ET le timing
|
|
62
|
+
* éteint (production) — le pont n'ouvre alors aucun profil.
|
|
63
|
+
*/
|
|
64
|
+
export declare class FrameProfile {
|
|
65
|
+
readonly requestId: string;
|
|
66
|
+
readonly type: string;
|
|
67
|
+
readonly scheme: string;
|
|
68
|
+
readonly method: string;
|
|
69
|
+
readonly url: string;
|
|
70
|
+
readonly remoteAddress: string | null;
|
|
71
|
+
readonly traceparent: string | null;
|
|
72
|
+
readonly security: ProfiledArea | null;
|
|
73
|
+
readonly securityTrace: ISecurityTrace | null;
|
|
74
|
+
/** Phases de CETTE frame (vide si le timing est éteint). */
|
|
75
|
+
readonly phases: PhaseTiming[];
|
|
76
|
+
/**
|
|
77
|
+
* Buffer ORM de CETTE frame — `null` hors profiling. C'est ce buffer que le
|
|
78
|
+
* kernel refusait d'allouer au handshake (il aurait cumulé N messages) : par
|
|
79
|
+
* invocation, il redevient exact, et le SQL se replace dans le waterfall.
|
|
80
|
+
*/
|
|
81
|
+
readonly profilerQueries: IProfilerQuery[] | null;
|
|
82
|
+
/** Resolver de la frame — porte route / controller / action. */
|
|
83
|
+
resolver: ProfiledResolver | null;
|
|
84
|
+
/** Statut HTTP-équivalent de l'invocation (200, 403, 404…). */
|
|
85
|
+
response: {
|
|
86
|
+
statusCode: number;
|
|
87
|
+
} | null;
|
|
88
|
+
error: {
|
|
89
|
+
message: string;
|
|
90
|
+
} | null;
|
|
91
|
+
private readonly timing;
|
|
92
|
+
constructor(init: FrameProfileInit);
|
|
93
|
+
/**
|
|
94
|
+
* Ouvre une phase. Contrairement au `Context`, l'index est un **scan arrière**
|
|
95
|
+
* (pas de `Map<nom, index>`) : une frame porte une poignée de phases, et une
|
|
96
|
+
* phase peut être ré-entrante (une action qui en déclenche une autre) — la
|
|
97
|
+
* table par nom, elle, écraserait la première occurrence.
|
|
98
|
+
*/
|
|
99
|
+
phaseStart(name: PhaseName): void;
|
|
100
|
+
/** Ferme la DERNIÈRE phase ouverte de ce nom (idempotent). */
|
|
101
|
+
phaseEnd(name: PhaseName): void;
|
|
102
|
+
/**
|
|
103
|
+
* Fige l'issue de l'invocation (statut + erreur éventuelle) avant collecte.
|
|
104
|
+
*
|
|
105
|
+
* @param status - statut HTTP-équivalent (200 ; 403/404/409… pour un refus).
|
|
106
|
+
* @param error - l'erreur qui a interrompu la frame, le cas échéant.
|
|
107
|
+
*/
|
|
108
|
+
finish(status: number, error?: unknown): void;
|
|
109
|
+
}
|
|
110
|
+
export default FrameProfile;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiler — collecteur **dev-only** de profils de requête, indexé par `requestId`.
|
|
3
|
+
*
|
|
4
|
+
* Modèle inversé (≠ ancien monitoring-bundle qui splicait du Twig serveur dans
|
|
5
|
+
* le body) : le serveur **collecte** un instantané en fin de requête, le client
|
|
6
|
+
* (debug bar) **rend**. La matière existe déjà dans le `Context` quand le timing
|
|
7
|
+
* est actif (`phases`, `requestId`, `status`, resolver) → `collect()` ne fait
|
|
8
|
+
* que SNAPSHOTTER : aucune allocation per-request hors de cette structure, et
|
|
9
|
+
* **rien** en prod (le module n'instancie pas le Profiler hors dev).
|
|
10
|
+
*
|
|
11
|
+
* Stockage = ring buffer borné via l'ordre d'insertion d'une `Map` : à la
|
|
12
|
+
* capacité, on évince la plus ancienne entrée (première clé). O(1) amorti.
|
|
13
|
+
*
|
|
14
|
+
* Corrélation client↔serveur : le framework renvoie déjà `X-Request-Id` sur
|
|
15
|
+
* chaque réponse → le client lit le header de SON appel AJAX et fetch
|
|
16
|
+
* `/nodefony/profiler/api/{requestId}`. Zéro modif du pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Multi-process : un profil vit sur le worker qui a traité l'appel (fetch même
|
|
19
|
+
* origine par requestId). Agrégat cluster = Redis (P13).
|
|
20
|
+
*/
|
|
21
|
+
/** Une phase du pipeline, projetée pour le transport (durée résolue). */
|
|
22
|
+
export interface ProfilePhase {
|
|
23
|
+
name: string;
|
|
24
|
+
/** Début relatif `performance.now()` (ms) — sert au calcul du waterfall. */
|
|
25
|
+
startMs: number;
|
|
26
|
+
/** Durée en ms, `null` si la phase ne s'est pas terminée (erreur). */
|
|
27
|
+
durationMs: number | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Une requête SQL/NoSQL exécutée pendant la requête HTTP (SEAM ORM — futur).
|
|
31
|
+
*
|
|
32
|
+
* Non collecté aujourd'hui : quand `@nodefony/orm-core` arrivera, les adapters
|
|
33
|
+
* pousseront leurs requêtes dans un buffer per-request dev-only (via l'ALS
|
|
34
|
+
* `RequestContext.getRequestId()`, déjà en place) que `collect()` lira ici.
|
|
35
|
+
* Le champ `queries` reste donc `undefined` tant qu'aucun ORM ne pushe → 0 coût.
|
|
36
|
+
*/
|
|
37
|
+
export interface ProfileQuery {
|
|
38
|
+
/** Requête (SQL ou commande NoSQL), tronquée si volumineuse. */
|
|
39
|
+
sql: string;
|
|
40
|
+
/** Début relatif (`performance.now()`) — même horloge que les phases. */
|
|
41
|
+
startMs?: number;
|
|
42
|
+
/** Durée d'exécution en ms. */
|
|
43
|
+
durationMs: number;
|
|
44
|
+
/** Lignes affectées/retournées, si connu. */
|
|
45
|
+
rows?: number;
|
|
46
|
+
/** Connecteur émetteur (`drizzle`, `mongoose`…). */
|
|
47
|
+
connector?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Traversée du firewall par cette requête — **ce qui était possible** (la zone :
|
|
51
|
+
* son nom, si elle est protégée, quels authenticators elle accepte) croisé avec
|
|
52
|
+
* **ce qui s'est passé** (quel maillon a résolu l'identité, l'issue, le motif
|
|
53
|
+
* d'un refus).
|
|
54
|
+
*
|
|
55
|
+
* Sans cela, une requête qui PASSE est invisible côté sécurité : le chemin de
|
|
56
|
+
* succès n'émet aucun événement d'audit (choix délibéré — le volume nominal
|
|
57
|
+
* n'est pas un signal). `undefined` hors zone firewall.
|
|
58
|
+
*/
|
|
59
|
+
export interface ProfileSecurity {
|
|
60
|
+
/** Nom de la zone traversée. */
|
|
61
|
+
zone: string | null;
|
|
62
|
+
/** La zone exige-t-elle une identité (`security: true`) ? */
|
|
63
|
+
protected: boolean;
|
|
64
|
+
/** Chaîne d'authenticators : `first` = le premier qui supporte, `all` = MFA. */
|
|
65
|
+
mode: string | null;
|
|
66
|
+
/** Authenticators que la zone accepte (ce qui était POSSIBLE). */
|
|
67
|
+
candidates: string[];
|
|
68
|
+
/** Authenticator qui a RÉELLEMENT résolu l'identité. */
|
|
69
|
+
authenticator: string | null;
|
|
70
|
+
/** `granted` · `anonymous` · `denied` · `failure` · `throttled` · `bypass` · `public`. */
|
|
71
|
+
outcome: string | null;
|
|
72
|
+
/** Motif du refus (`no_credentials`, `invalid_credentials`…). */
|
|
73
|
+
reason: string | null;
|
|
74
|
+
/** Rôles du token résolu (l'axe autorisation). */
|
|
75
|
+
roles: string[] | null;
|
|
76
|
+
}
|
|
77
|
+
/** Profil complet d'une requête (HTTP ou message WS), exposé par `get`. */
|
|
78
|
+
export interface ProfileEntry {
|
|
79
|
+
requestId: string;
|
|
80
|
+
/** Horodatage de la collecte (`Date.now()`), pour le tri client. */
|
|
81
|
+
ts: number;
|
|
82
|
+
kind: "http" | "ws";
|
|
83
|
+
method: string | null;
|
|
84
|
+
url: string;
|
|
85
|
+
scheme: string;
|
|
86
|
+
status: number | null;
|
|
87
|
+
/** Durée totale serveur (ms), dérivée des phases. */
|
|
88
|
+
durationMs: number | null;
|
|
89
|
+
route: string | null;
|
|
90
|
+
controller: string | null;
|
|
91
|
+
action: string | null;
|
|
92
|
+
remoteAddress: string | null;
|
|
93
|
+
/** Identité (username) si le firewall l'a injectée, sinon `null`. */
|
|
94
|
+
user: string | null;
|
|
95
|
+
/** `traceparent` W3C Trace Context (P2.7) — corrélation distribuée RFC-propre. */
|
|
96
|
+
traceparent: string | null;
|
|
97
|
+
error: string | null;
|
|
98
|
+
phases: ProfilePhase[];
|
|
99
|
+
/** Requêtes ORM (SEAM futur — `undefined` tant qu'aucun adapter ne pushe). */
|
|
100
|
+
queries?: ProfileQuery[];
|
|
101
|
+
/** Traversée du firewall — `undefined` si la requête n'a croisé aucune zone. */
|
|
102
|
+
security?: ProfileSecurity;
|
|
103
|
+
}
|
|
104
|
+
/** Résumé léger d'un profil pour la liste `recent` (sans les phases). */
|
|
105
|
+
export interface ProfileSummary {
|
|
106
|
+
requestId: string;
|
|
107
|
+
ts: number;
|
|
108
|
+
kind: "http" | "ws";
|
|
109
|
+
method: string | null;
|
|
110
|
+
url: string;
|
|
111
|
+
status: number | null;
|
|
112
|
+
durationMs: number | null;
|
|
113
|
+
route: string | null;
|
|
114
|
+
error: string | null;
|
|
115
|
+
}
|
|
116
|
+
/** Forme structurelle minimale lue sur un `Context` (lecture défensive). */
|
|
117
|
+
interface ProfilableContext {
|
|
118
|
+
requestId?: string;
|
|
119
|
+
type?: string;
|
|
120
|
+
scheme?: string;
|
|
121
|
+
method?: string | null;
|
|
122
|
+
url?: string;
|
|
123
|
+
remoteAddress?: string | null;
|
|
124
|
+
traceparent?: string | null;
|
|
125
|
+
error?: {
|
|
126
|
+
message?: string;
|
|
127
|
+
} | null;
|
|
128
|
+
response?: {
|
|
129
|
+
statusCode?: number;
|
|
130
|
+
} | null;
|
|
131
|
+
phases?: ReadonlyArray<{
|
|
132
|
+
name: string;
|
|
133
|
+
startMs: number;
|
|
134
|
+
durationMs?: number;
|
|
135
|
+
}>;
|
|
136
|
+
resolver?: {
|
|
137
|
+
route?: {
|
|
138
|
+
name?: string;
|
|
139
|
+
} | null;
|
|
140
|
+
controller?: {
|
|
141
|
+
name?: string;
|
|
142
|
+
} | null;
|
|
143
|
+
actionName?: string;
|
|
144
|
+
} | null;
|
|
145
|
+
/**
|
|
146
|
+
* Buffer de requêtes ORM rempli pendant la requête (dev-only). Même tableau
|
|
147
|
+
* que la payload ALS — les adapters ORM y poussent via `RequestContext`.
|
|
148
|
+
* `null`/absent hors profiling.
|
|
149
|
+
*/
|
|
150
|
+
profilerQueries?: ProfileQuery[] | null;
|
|
151
|
+
/**
|
|
152
|
+
* Zone firewall capturée (`SecuredArea`, posée par `Firewall.isSecure`). Lue
|
|
153
|
+
* en structurel : `@nodefony/http` ne peut pas importer `@nodefony/security`.
|
|
154
|
+
*/
|
|
155
|
+
security?: {
|
|
156
|
+
name?: string;
|
|
157
|
+
security?: boolean;
|
|
158
|
+
mode?: string;
|
|
159
|
+
authenticators?: readonly string[];
|
|
160
|
+
} | null;
|
|
161
|
+
/** Décision du firewall sur cette requête (dev-only, cf `ISecurityTrace`). */
|
|
162
|
+
securityTrace?: {
|
|
163
|
+
authenticator: string | null;
|
|
164
|
+
outcome: string;
|
|
165
|
+
reason: string | null;
|
|
166
|
+
user: string | null;
|
|
167
|
+
roles: string[] | null;
|
|
168
|
+
} | null;
|
|
169
|
+
}
|
|
170
|
+
export declare class Profiler {
|
|
171
|
+
private readonly _buf;
|
|
172
|
+
private readonly _cap;
|
|
173
|
+
constructor(cap?: number);
|
|
174
|
+
/**
|
|
175
|
+
* Snapshot un `Context` en fin de requête. Tolérant aux champs absents
|
|
176
|
+
* (handshake WS, erreur précoce). À appeler AVANT `context.clean()`.
|
|
177
|
+
*
|
|
178
|
+
* @param ctx - le Context HTTP/WS terminé (lu en structurel, jamais muté).
|
|
179
|
+
*/
|
|
180
|
+
collect(ctx: ProfilableContext): void;
|
|
181
|
+
/** Profil complet d'une requête, ou `undefined` si évincé/inconnu. */
|
|
182
|
+
get(requestId: string): ProfileEntry | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* Résumés des requêtes les plus récentes (récent → ancien), capés à `limit`.
|
|
185
|
+
*/
|
|
186
|
+
recent(limit?: number): ProfileSummary[];
|
|
187
|
+
/** Nombre de profils en mémoire. */
|
|
188
|
+
get size(): number;
|
|
189
|
+
/** Vide le ring buffer. */
|
|
190
|
+
clear(): void;
|
|
191
|
+
}
|
|
192
|
+
export default Profiler;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Générateurs de configuration reverse-proxy (nginx / haproxy) DÉRIVÉE de
|
|
3
|
+
* l'introspection Nodefony — fonctions PURES (aucun accès kernel/fs), testables
|
|
4
|
+
* et sérialisables. Le câblage (lecture des services) vit dans la commande CLI
|
|
5
|
+
* `proxy:generate` ; ici on ne fait que transformer un modèle → texte de conf.
|
|
6
|
+
*
|
|
7
|
+
* Résout le « trou statiques multi-modules » : Nodefony sert N dossiers `public/`
|
|
8
|
+
* (racine + un par module) AU MÊME préfixe `/`. nginx ne sait pas servir N roots
|
|
9
|
+
* sous `/` → on génère une CHAÎNE de `try_files` via des locations nommées
|
|
10
|
+
* (root d0 → @r1 → root d1 → … → @nodefony), fallback final vers le backend.
|
|
11
|
+
*/
|
|
12
|
+
/** Un dossier statique servi sous un préfixe d'URL (mount préfixé). */
|
|
13
|
+
export interface ProxyStaticMount {
|
|
14
|
+
/** Préfixe d'URL (ex. `/_assets/studio/`). */
|
|
15
|
+
prefix: string;
|
|
16
|
+
/** Dossier absolu servi. */
|
|
17
|
+
dir: string;
|
|
18
|
+
}
|
|
19
|
+
/** Modèle d'introspection consommé par les générateurs. */
|
|
20
|
+
export interface ProxyIntrospection {
|
|
21
|
+
/** `server_name` (hôtes de confiance, IP exclues). Vide → `_` (catch-all). */
|
|
22
|
+
domains: string[];
|
|
23
|
+
/** Hôte du backend Nodefony à joindre (ex. `host.docker.internal`). */
|
|
24
|
+
backendHost: string;
|
|
25
|
+
/** Port HTTP du backend (clair). */
|
|
26
|
+
httpPort: number;
|
|
27
|
+
/** Port HTTPS/2 du backend (re-encrypt). */
|
|
28
|
+
httpsPort: number;
|
|
29
|
+
/** Dossiers statiques servis à la racine `/` (ordre = priorité). */
|
|
30
|
+
staticRoots: string[];
|
|
31
|
+
/** Montages statiques préfixés (servis tels quels). */
|
|
32
|
+
mounts: ProxyStaticMount[];
|
|
33
|
+
/** Port d'écoute du proxy généré (défaut 80). */
|
|
34
|
+
listen: number;
|
|
35
|
+
/** Re-chiffrer vers le backend HTTPS (true) ou forward en clair (false). */
|
|
36
|
+
reencrypt: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Taille maximale d'un corps de requête acceptée par Nodefony, en octets
|
|
39
|
+
* (`http.maxBodySize`). `0` = ne rien imposer au proxy.
|
|
40
|
+
*
|
|
41
|
+
* Sans elle, nginx applique son propre défaut — **1 Mo** — et rend un `413`
|
|
42
|
+
* que le serveur ne voit jamais : l'application marche en direct et casse
|
|
43
|
+
* derrière le proxy, sur une limite que personne n'a écrite.
|
|
44
|
+
*/
|
|
45
|
+
maxBodyBytes: number;
|
|
46
|
+
/**
|
|
47
|
+
* Intervalle du heartbeat WebSocket, en millisecondes (`ws.keepaliveInterval`,
|
|
48
|
+
* `0` = désactivé) — d'où les proxys tirent leur délai de tunnel.
|
|
49
|
+
*
|
|
50
|
+
* Une WebSocket est, vue d'un proxy, une connexion SANS trafic entre deux
|
|
51
|
+
* messages. Ce qui la garde en vie derrière nginx et haproxy, ce sont les
|
|
52
|
+
* pings du serveur : le délai d'inactivité doit donc être dérivé de leur
|
|
53
|
+
* intervalle, jamais posé au hasard. Heartbeat désactivé → plus rien ne borne
|
|
54
|
+
* le silence, et seul un délai franchement long évite de couper des sockets
|
|
55
|
+
* saines.
|
|
56
|
+
*/
|
|
57
|
+
keepaliveIntervalMs: number;
|
|
58
|
+
}
|
|
59
|
+
/** Valeurs par défaut d'un modèle d'introspection (complété par la commande). */
|
|
60
|
+
export declare const defaultIntrospection: ProxyIntrospection;
|
|
61
|
+
/**
|
|
62
|
+
* Génère une configuration nginx complète (reverse-proxy + offload statiques).
|
|
63
|
+
*
|
|
64
|
+
* @param intro - modèle d'introspection Nodefony.
|
|
65
|
+
* @returns le contenu d'un `nginx.conf`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function generateNginxConfig(intro: ProxyIntrospection): string;
|
|
68
|
+
/**
|
|
69
|
+
* Génère une configuration haproxy (reverse-proxy + Forwarded RFC 7239).
|
|
70
|
+
* haproxy ne sert PAS de fichiers : les statiques sont proxifiés au backend
|
|
71
|
+
* (ou désactivés côté serveur via `statics.enabled: false` + un nginx/CDN).
|
|
72
|
+
*
|
|
73
|
+
* @param intro - modèle d'introspection Nodefony.
|
|
74
|
+
* @returns le contenu d'un `haproxy.cfg`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function generateHaproxyConfig(intro: ProxyIntrospection): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrat d'un compteur de **rate-limit général par IP** (P0.3) et son verdict.
|
|
3
|
+
*
|
|
4
|
+
* À NE PAS confondre avec `security.rateLimit` (backoff de LOGIN NIST, par
|
|
5
|
+
* identifiant saisi). Ici : plafond de trafic par IP cliente, sur TOUTES les
|
|
6
|
+
* routes HTTP, matérialisé par les en-têtes `X-RateLimit-*` + un `429` (RFC 6585).
|
|
7
|
+
*
|
|
8
|
+
* Le contrat est **SYNCHRONE** à dessein : `hit()` est appelé sur le hot-path de
|
|
9
|
+
* CHAQUE requête → aucune `Promise`, aucune microtask. Un futur store distribué
|
|
10
|
+
* (Redis, multi-pod) introduira son propre chemin d'exécution, pas ce contrat.
|
|
11
|
+
*/
|
|
12
|
+
import type { IPage, IPageQuery } from "nodefony";
|
|
13
|
+
/**
|
|
14
|
+
* Verdict rendu par {@link IRateLimitStore.hit} — porte tout le nécessaire pour
|
|
15
|
+
* émettre les en-têtes `X-RateLimit-*` (+ `Retry-After` en cas de rejet) sans
|
|
16
|
+
* relire l'état du store.
|
|
17
|
+
*/
|
|
18
|
+
export interface RateLimitVerdict {
|
|
19
|
+
/** `true` si la requête dépasse le quota de la fenêtre → réponse `429`. */
|
|
20
|
+
readonly limited: boolean;
|
|
21
|
+
/** Plafond de la fenêtre — en-tête `X-RateLimit-Limit`. */
|
|
22
|
+
readonly limit: number;
|
|
23
|
+
/** Requêtes restantes dans la fenêtre courante (`X-RateLimit-Remaining`, ≥ 0). */
|
|
24
|
+
readonly remaining: number;
|
|
25
|
+
/** Fin de la fenêtre courante, en **ms epoch** (`X-RateLimit-Reset` = `⌈/1000⌉`). */
|
|
26
|
+
readonly resetAtMs: number;
|
|
27
|
+
/** Secondes jusqu'au reset (`Retry-After`) ; `0` si la requête n'est pas limitée. */
|
|
28
|
+
readonly retryAfterS: number;
|
|
29
|
+
}
|
|
30
|
+
/** Options de construction d'un {@link IRateLimitStore}. */
|
|
31
|
+
export interface IRateLimitOptions {
|
|
32
|
+
/** Largeur de la fenêtre fixe, en **millisecondes**. */
|
|
33
|
+
readonly windowMs: number;
|
|
34
|
+
/** Nombre max de requêtes autorisées par clé (IP) et par fenêtre. */
|
|
35
|
+
readonly max: number;
|
|
36
|
+
/** Borne mémoire : nombre max de clés (IP) suivies simultanément. */
|
|
37
|
+
readonly maxTracked: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Une clé (IP) actuellement suivie, telle qu'exposée à l'INTROSPECTION admin —
|
|
41
|
+
* l'état, jamais le trafic : ni URL, ni en-tête, ni corps. Une IP reste une
|
|
42
|
+
* donnée personnelle → ce listing est réservé au data plane admin.
|
|
43
|
+
*/
|
|
44
|
+
export interface IRateLimitEntry {
|
|
45
|
+
/** Clé suivie (IP cliente résolue). */
|
|
46
|
+
readonly key: string;
|
|
47
|
+
/** Hits comptés dans la fenêtre courante. */
|
|
48
|
+
readonly count: number;
|
|
49
|
+
/** Fin de la fenêtre courante (ms epoch). */
|
|
50
|
+
readonly resetAtMs: number;
|
|
51
|
+
/** `true` si la clé a dépassé le plafond de la fenêtre (elle prend des 429). */
|
|
52
|
+
readonly limited: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Requête de listing des clés suivies — {@link IPageQuery} + le seul filtre qui
|
|
56
|
+
* a un sens ici. `q` (hérité) = préfixe de clé (« 10.0. » pour un sous-réseau),
|
|
57
|
+
* pas une sous-chaîne : sur une IP, seul le préfixe est signifiant.
|
|
58
|
+
*/
|
|
59
|
+
export interface IRateLimitListQuery extends IPageQuery {
|
|
60
|
+
/** `true` = seulement les clés au plafond, `false` = seulement les autres. */
|
|
61
|
+
limited?: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Compteur de rate-limit par clé (IP). Implémentation par défaut :
|
|
65
|
+
* {@link import("./MemoryRateLimitStore").MemoryRateLimitStore} (fenêtre fixe,
|
|
66
|
+
* en mémoire).
|
|
67
|
+
*/
|
|
68
|
+
export interface IRateLimitStore {
|
|
69
|
+
/**
|
|
70
|
+
* Enregistre un hit pour `key` (IP cliente résolue) et renvoie le verdict de
|
|
71
|
+
* la fenêtre courante. Synchrone, O(1).
|
|
72
|
+
*/
|
|
73
|
+
hit(key: string): RateLimitVerdict;
|
|
74
|
+
/**
|
|
75
|
+
* Purge les fenêtres expirées (`resetAt <= now`). Appelé hors hot-path par le
|
|
76
|
+
* `GcScheduler` du core.
|
|
77
|
+
*
|
|
78
|
+
* @param nowMs - horloge injectable (ms epoch) ; défaut = horloge du store.
|
|
79
|
+
* @returns le nombre d'entrées purgées.
|
|
80
|
+
*/
|
|
81
|
+
gc(nowMs?: number): number;
|
|
82
|
+
/**
|
|
83
|
+
* Page des clés suivies — introspection admin (« qui me martèle ? »). Ne
|
|
84
|
+
* matérialise jamais plus d'une page, filtres appliqués au store.
|
|
85
|
+
*
|
|
86
|
+
* **Asynchrone** alors que {@link IRateLimitStore.hit} est synchrone : ce
|
|
87
|
+
* listing est hors hot-path (console admin), et un futur store distribué le
|
|
88
|
+
* servira par `SCAN`. La contrainte « zéro Promise » ne vaut que pour `hit`.
|
|
89
|
+
*
|
|
90
|
+
* Ordre contractuel : `count` DESC (les plus bruyants d'abord — c'est LA
|
|
91
|
+
* question d'exploitation), départagé par `key` ASC.
|
|
92
|
+
*/
|
|
93
|
+
listPage(query: IRateLimitListQuery): Promise<IPage<IRateLimitEntry>>;
|
|
94
|
+
/** Nombre de clés (IP) actuellement suivies — introspection / métrique. */
|
|
95
|
+
readonly trackedCount: number;
|
|
96
|
+
/** Total cumulé de requêtes rejetées (`429`) depuis le boot — métrique. */
|
|
97
|
+
readonly rejectedTotal: number;
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IPage } from "nodefony";
|
|
2
|
+
import type { IRateLimitEntry, IRateLimitListQuery, IRateLimitOptions, IRateLimitStore, RateLimitVerdict } from "./IRateLimitStore.js";
|
|
3
|
+
/**
|
|
4
|
+
* Store de rate-limit **en mémoire** — algorithme *fixed window* par clé (IP).
|
|
5
|
+
*
|
|
6
|
+
* Une entrée par IP `{ count, resetAt }` ; à l'expiration de la fenêtre le
|
|
7
|
+
* compteur repart à zéro (mutation in-place, 0 alloc pour une IP récurrente).
|
|
8
|
+
* `hit()` est O(1) (1 `Map.get` + arithmétique), et la `Map` est allouée en
|
|
9
|
+
* **lazy** au 1ᵉʳ hit → 0 coût mémoire si le rate-limit n'est jamais sollicité.
|
|
10
|
+
*
|
|
11
|
+
* Mémoire **bornée** par `maxTracked` : au cap, on purge d'abord les fenêtres
|
|
12
|
+
* expirées puis on évince en FIFO (ordre d'insertion `Map`). Un {@link gc}
|
|
13
|
+
* planifiable (GcScheduler du core) fait le ménage hors hot-path.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ Limite assumée (fenêtre fixe) : un pic à cheval sur deux fenêtres peut
|
|
16
|
+
* laisser passer jusqu'à `2 × max` sur un court intervalle. Acceptable pour une
|
|
17
|
+
* défense de capacité ; un *sliding window* viendrait en option si nécessaire.
|
|
18
|
+
*/
|
|
19
|
+
export declare class MemoryRateLimitStore implements IRateLimitStore {
|
|
20
|
+
#private;
|
|
21
|
+
/**
|
|
22
|
+
* @param options - fenêtre, plafond, borne mémoire.
|
|
23
|
+
* @param now - horloge injectable (ms) — `Date.now` par défaut, surchargée en test.
|
|
24
|
+
*/
|
|
25
|
+
constructor(options: IRateLimitOptions, now?: () => number);
|
|
26
|
+
hit(key: string): RateLimitVerdict;
|
|
27
|
+
gc(nowMs?: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* {@inheritDoc IRateLimitStore.listPage}
|
|
30
|
+
*
|
|
31
|
+
* La collection est déjà en RAM et **bornée par `maxTracked`** (c'est la
|
|
32
|
+
* nature de ce store) : le tri porte sur des références, seule la page est
|
|
33
|
+
* matérialisée en objets de sortie. Les fenêtres expirées sont exclues à la
|
|
34
|
+
* lecture — les montrer ferait passer un compteur mort pour du trafic vivant
|
|
35
|
+
* (le `gc` les retire plus tard, hors hot-path).
|
|
36
|
+
*/
|
|
37
|
+
listPage(query: IRateLimitListQuery): Promise<IPage<IRateLimitEntry>>;
|
|
38
|
+
get trackedCount(): number;
|
|
39
|
+
get rejectedTotal(): number;
|
|
40
|
+
}
|