@nodefony/framework 10.0.0-beta.6 → 10.0.0-beta.7

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.
Files changed (38) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/nodefony/controller/CspReportController.js +2 -2
  5. package/dist/nodefony/controller/IssuerMetadataController.js +2 -2
  6. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +74 -9
  7. package/dist/nodefony/controller/SessionAuthController.js +1 -1
  8. package/dist/nodefony/decorators/routerDecorators.js +101 -13
  9. package/dist/nodefony/grpc/GrpcController.js +138 -0
  10. package/dist/nodefony/grpc/GrpcService.js +75 -0
  11. package/dist/nodefony/grpc/declarations.js +33 -0
  12. package/dist/nodefony/grpc/index.js +3 -0
  13. package/dist/nodefony/service/AdminBroker.js +1 -1
  14. package/dist/nodefony/service/IdempotencyStore.js +1 -1
  15. package/dist/nodefony/service/router.js +11 -9
  16. package/dist/nodefony/src/Controller.js +21 -3
  17. package/dist/nodefony/src/Resolver.js +83 -43
  18. package/dist/nodefony/src/Route.js +45 -45
  19. package/dist/nodefony/src/loginPage.js +1 -1
  20. package/dist/types/index.d.ts +1 -0
  21. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +21 -1
  22. package/dist/types/nodefony/decorators/routerDecorators.d.ts +2 -1
  23. package/dist/types/nodefony/grpc/GrpcController.d.ts +75 -0
  24. package/dist/types/nodefony/grpc/GrpcService.d.ts +51 -0
  25. package/dist/types/nodefony/grpc/declarations.d.ts +12 -0
  26. package/dist/types/nodefony/grpc/index.d.ts +13 -0
  27. package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
  28. package/dist/types/nodefony/service/router.d.ts +5 -4
  29. package/dist/types/nodefony/src/Controller.d.ts +15 -3
  30. package/dist/types/nodefony/src/Resolver.d.ts +34 -7
  31. package/dist/types/nodefony/src/Route.d.ts +35 -0
  32. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  33. package/docs/controller.md +89 -9
  34. package/docs/grpc.md +690 -0
  35. package/docs/idempotence.md +11 -6
  36. package/docs/index.md +12 -8
  37. package/docs/routing.md +30 -2
  38. package/package.json +24 -8
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@nodefony/framework/grpc` — déclarer un service gRPC dans un module
3
+ * (ADR-0026 D1).
4
+ *
5
+ * Sous-chemin à part : le point d'entrée principal de `@nodefony/framework`
6
+ * ne cite ni Connect ni protobuf (D12). L'importer charge Connect au
7
+ * démarrage — ce que fait tout contrôleur gRPC, et seulement lui.
8
+ *
9
+ * @experimental Surface gRPC, ADR-0026 D11.
10
+ */
11
+ export { GrpcController } from "./GrpcController.js";
12
+ export { GrpcService } from "./GrpcService.js";
13
+ export type { GrpcServiceClass } from "./GrpcService.js";
@@ -1,4 +1,4 @@
1
- import type { contextRequest, HTTPMethod, HttpResponse, Http2Response, WebsocketResponse, Session, ContextType } from "@nodefony/http";
1
+ import type { contextRequest, HTTPMethod, HttpResponse, Http2Response, WebsocketResponse, Session, ContextType, IUploadedFile } from "@nodefony/http";
2
2
  import type { Module, FileClass, MaybePromise } from "nodefony";
3
3
  import type { OutgoingHttpHeaders } from "node:http";
4
4
  import type { ReadStream } from "node:fs";
@@ -11,7 +11,7 @@ export interface IController {
11
11
  session?: Session | null;
12
12
  method?: HTTPMethod | undefined;
13
13
  query: Record<string, unknown>;
14
- files: readonly unknown[];
14
+ files: readonly IUploadedFile[];
15
15
  body: Record<string, unknown>;
16
16
  module?: Module | undefined;
17
17
  setContext(context: ContextType): void;
@@ -60,10 +60,11 @@ declare class Router extends Service implements IRequestRouter {
60
60
  * lieu de `context.request.url` — permet de router un path **porté par un message**
61
61
  * (WS-RPC `invoke`) vers une action, sans muter l'URL de la connexion (état partagé).
62
62
  * `undefined` (cas hot path normal) → comportement inchangé.
63
- * @param methodOverride - méthode HTTP **logique** à exiger en plus du transport
64
- * WEBSOCKET (pont WS-RPC `api.request` d'une MUTATION) : lève l'ambiguïté
65
- * GET-via-WS / POST-via-WS sur un même chemin (`context.method` = "WEBSOCKET").
66
- * `undefined` (GET/HTTP) → match historique sur `context.method`.
63
+ * @param methodOverride - méthode HTTP **logique** du pont WS-RPC `api.request`,
64
+ * GET compris, exigée en plus du transport WEBSOCKET par la règle du cœur
65
+ * `bridgeServesVerb` : un GET n'atteint jamais une action POST du même
66
+ * chemin. `undefined` (requête HTTP, ouverture de socket) → match sur
67
+ * `context.method`.
67
68
  * @returns un `Resolver` (`.resolve === true` si une route a matché) — ou,
68
69
  * pour un chemin servi en WebSocket SEULEMENT et demandé en HTTP, un
69
70
  * `Resolver` dont `exception` porte le `426` (en-têtes `Upgrade` posés) :
@@ -3,7 +3,7 @@ import type { DefaultEventsMap, EventsMap, IRequestUser, MaybePromise } from "no
3
3
  import type { IController } from "../interfaces/index.js";
4
4
  import Route from "./Route.js";
5
5
  import { contextRequest, HTTPMethod, HttpResponse, Session, ContextType, Http2Response, WebsocketResponse } from "@nodefony/http";
6
- import type { ByteStreamSource, IByteStreamOptions, IByteStreamResult, ISseStreamOptions, SseStream } from "@nodefony/http";
6
+ import type { ByteStreamSource, IByteStreamOptions, IByteStreamResult, ISseStreamOptions, IUploadedFile, SseStream } from "@nodefony/http";
7
7
  import { OutgoingHttpHeaders } from "node:http";
8
8
  import { ReadStream } from "node:fs";
9
9
  import Eta from "../service/Eta.js";
@@ -69,6 +69,7 @@ export type ControllerScope = "request" | "singleton";
69
69
  * @param config - section `fileServing` validée
70
70
  */
71
71
  export declare function configureFileServing(config: IFileServingConfig): void;
72
+ export declare function contentDisposition(type: "attachment" | "inline", name: string): string;
72
73
  declare class Controller extends Service implements IController {
73
74
  #private;
74
75
  static prefix: string;
@@ -144,8 +145,8 @@ declare class Controller extends Service implements IController {
144
145
  * texte du même corps sont dans {@link body}. Pour une action typée,
145
146
  * préférer `@UploadedFiles()`.
146
147
  */
147
- get files(): readonly unknown[];
148
- set files(value: readonly unknown[]);
148
+ get files(): readonly IUploadedFile[];
149
+ set files(value: readonly IUploadedFile[]);
149
150
  /**
150
151
  * Le CORPS de la requête, parsé — nom universel de l'écosystème (Express,
151
152
  * Fastify, NestJS). Jamais un paramètre d'URL : ceux-là sont dans
@@ -290,6 +291,17 @@ declare class Controller extends Service implements IController {
290
291
  * });
291
292
  * ```
292
293
  *
294
+ * HEAD sans lancer le producteur — toute route GET sert HEAD, la source n'y
295
+ * est jamais lue. `Readable.from([])` seul est en mode OBJET, donc refusé
296
+ * (500) : le mode octets se demande.
297
+ * ```ts
298
+ * if (this.method === "HEAD") {
299
+ * return this.renderStream(Readable.from([], { objectMode: false }), {
300
+ * headers: { "content-type": "video/mp4" },
301
+ * });
302
+ * }
303
+ * ```
304
+ *
293
305
  * @param source - flux Node, ou flux web ; jamais en mode objet.
294
306
  * @param options - statut, en-têtes, `idleTimeout`, `stallTimeout`,
295
307
  * `maxDuration`, `onClose(reason)`.
@@ -58,12 +58,12 @@ declare class Resolver implements IResolver {
58
58
  */
59
59
  queryOverride: Record<string, unknown> | null;
60
60
  /**
61
- * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`
62
- * quand c'est une MUTATION (POST/PUT/PATCH/DELETE). Posée par
63
- * `Router.resolve(ctx, cleanPath, methodOverride)` → consommée par `match()`
64
- * pour désambiguïser, sur le transport WEBSOCKET unique, la route logique
65
- * visée (cf `Route.matchRequirements`). `null` (GET/HTTP) = match historique
66
- * sur `context.method`.
61
+ * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`,
62
+ * GET compris. Posée par `Router.resolve(ctx, cleanPath, methodOverride)` →
63
+ * consommée par `match()` pour désambiguïser, sur le transport WEBSOCKET
64
+ * unique, la route logique visée (règle `bridgeServesVerb` du cœur, cf
65
+ * `Route.matchRequirements`). `null` (requête HTTP, ouverture de socket) =
66
+ * match sur `context.method`.
67
67
  */
68
68
  methodOverride: string | null;
69
69
  /**
@@ -363,7 +363,15 @@ declare class Resolver implements IResolver {
363
363
  /**
364
364
  * Rend la valeur d'une action sur le transport, selon sa nature : promesse
365
365
  * (démêlée puis re-rendue), texte, Buffer, réponse déjà construite, scalaire
366
- * ou objet (auto-JSON), rien (l'action envoie elle-même).
366
+ * ou objet (auto-JSON), rien (l'action envoie elle-même) — et, en HTTP, une
367
+ * `Response` Web standard (`fetch()`, `new Response(…)`, `Response.json(…)`)
368
+ * ou un flux d'octets nu (`Readable` Node, `ReadableStream` Web), envoyés EN
369
+ * FLUX par `HttpContext.openWebResponse` / `HttpContext.openStream`.
370
+ *
371
+ * Ces deux-là sont reconnus APRÈS l'objet simple et le tableau : l'auto-JSON,
372
+ * le cas courant, ne paie aucun test de plus. Une réponse déjà partie
373
+ * (`sended`) n'est jamais réécrite ; une `Response` renvoyée en plus voit
374
+ * alors son corps annulé, pour ne pas retenir l'amont.
367
375
  *
368
376
  * Pas `async` : l'envelopper dans une promesse de plus ne changeait rien au
369
377
  * rendu, et coûtait une suspension à chaque requête (#505). La valeur n'est
@@ -375,6 +383,25 @@ declare class Resolver implements IResolver {
375
383
  * @throws de façon SYNCHRONE ce que lève un envoi synchrone.
376
384
  */
377
385
  returnController(result: unknown): MaybePromise<unknown>;
386
+ /**
387
+ * Aiguille une valeur OBJET rendue par l'action — dans l'ordre de fréquence :
388
+ * le tableau et l'objet simple (auto-JSON) d'abord, les instances rares
389
+ * ensuite. Partie de {@link Resolver.returnController}.
390
+ *
391
+ * @param result - un objet non nul.
392
+ * @returns ce que rend l'envoi, ou `undefined` quand il n'y a rien à envoyer.
393
+ */
394
+ private returnObject;
395
+ /**
396
+ * Ce qui arrive quand l'action ne rend RIEN d'envoyable (`undefined`,
397
+ * `null`, une fonction…) : en HTTP, la redirection posée ou le statut sans
398
+ * corps part ; sinon la requête est marquée non rendue (`waitAsync`). Partie
399
+ * de {@link Resolver.returnController}.
400
+ *
401
+ * @param result - la valeur rendue par l'action.
402
+ * @returns l'envoi d'une redirection ou d'un statut sans corps, sinon `undefined`.
403
+ */
404
+ private returnNothing;
378
405
  /** Statut courant de la réponse (0 si le transport n'en porte pas). */
379
406
  private getResponseStatus;
380
407
  /**
@@ -1,4 +1,5 @@
1
1
  import { HTTPMethod, SchemeType, ContextType } from "@nodefony/http";
2
+ import type { IRouteUploadOptions } from "@nodefony/http";
2
3
  import type { IRoute } from "../interfaces/index.js";
3
4
  import type { RouteActionMeta } from "../decorators/routerDecorators.js";
4
5
  import Controller from "./Controller.js";
@@ -77,6 +78,26 @@ export interface RouteOptions {
77
78
  * aucun coût par requête. Défaut `false`.
78
79
  */
79
80
  fallback?: boolean;
81
+ /**
82
+ * Délai de réponse de CETTE route (ms), à la place du `responseTimeout` du
83
+ * serveur — dans les deux sens : un export lent le relève sans relever celui
84
+ * des autres routes, une route qui doit répondre vite l'abaisse.
85
+ *
86
+ * Même nature que celui du serveur : un délai d'INACTIVITÉ. Une action qui
87
+ * envoie au fil de l'eau (flux, SSE) n'est pas coupée ; une action muette au
88
+ * delà reçoit 503 et son `ctx.signal` est annulé. Entier de 0 à 2³¹−1 ;
89
+ * `0` désactive le délai pour la route. Absent = celui du serveur.
90
+ */
91
+ responseTimeout?: number;
92
+ /**
93
+ * Bornes d'envoi de CETTE route, à la place de celles de la configuration
94
+ * http (`upload.*`) — dans les deux sens, clé par clé : un dépôt de vidéos
95
+ * relève `maxFileSize`, un avatar l'abaisse, et le fichier trop gros reçoit
96
+ * 413 PENDANT son flux, avant d'avoir rempli le disque. `hashAlgorithm` y
97
+ * demande l'empreinte de chaque fichier reçu. Entiers ≥ 1 ; une clé absente
98
+ * garde la valeur de la configuration. Exclu avec `bodyStream: true`.
99
+ */
100
+ upload?: IRouteUploadOptions;
80
101
  }
81
102
  export interface RouteRequirements {
82
103
  domain?: string | string[] | undefined;
@@ -135,6 +156,13 @@ declare class Route implements IRoute {
135
156
  areaRoleExempt: boolean;
136
157
  /** Route de repli, résolue après toutes les autres. Cf {@link RouteOptions.fallback}. */
137
158
  fallback: boolean;
159
+ /**
160
+ * Délai de réponse PROPRE à la route (ms), `null` = celui du serveur.
161
+ * Cf {@link RouteOptions.responseTimeout}.
162
+ */
163
+ responseTimeout: number | null;
164
+ /** Bornes d'envoi propres à la route. Cf {@link RouteOptions.upload}. */
165
+ upload: IRouteUploadOptions | null;
138
166
  /**
139
167
  * P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
140
168
  * (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
@@ -142,6 +170,13 @@ declare class Route implements IRoute {
142
170
  * ensuite). Lu en amont par `handleHttp` pour sauter le parse.
143
171
  */
144
172
  bodyStream?: boolean | undefined;
173
+ /**
174
+ * Route servie par un point d'entrée gRPC — posé au démarrage par
175
+ * `markGrpcRoute` (`@nodefony/http/grpc`), jamais à la main.
176
+ *
177
+ * @experimental Surface gRPC, ADR-0026 D11.
178
+ */
179
+ grpc?: boolean | undefined;
145
180
  /**
146
181
  * P5 — Metadata d'action figées (`@HttpCode`/`@Header`/`@Redirect`/params/
147
182
  * session), memo au 1er hit comme {@link bodyStream} : `undefined` = pas
@@ -141,4 +141,4 @@ export declare function buildLoginPageView(page: ILoginPageDescription, request:
141
141
  * d'empreinte (`i-finger`) reprend `IconFingerprint` de Tabler Icons (MIT),
142
142
  * celle du bouton de Studio : les deux écrans montrent le même geste.
143
143
  */
144
- export declare const LOGIN_PAGE_TEMPLATE = "<!doctype html>\n<html lang=\"fr\"<% if (it.theme) { %> data-theme=\"<%= it.theme %>\"<% } %>>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark light\" />\n <meta name=\"robots\" content=\"noindex\" />\n <% if (it.nonce) { %><meta property=\"csp-nonce\" nonce=\"<%= it.nonce %>\"><% } %>\n <title><%= it.title %> \u2014 <%= it.brand %></title>\n <link rel=\"icon\" href=\"<%= it.logoHref %>\" type=\"image/svg+xml\" />\n<% if (!it.theme) { %>\n <script nonce=\"<%= it.nonce %>\">try{var t=localStorage.getItem(\"nf-login-theme\");if(t===\"light\"||t===\"dark\")document.documentElement.dataset.theme=t}catch(e){}</script>\n<% } %>\n <link rel=\"stylesheet\" href=\"<%= it.styleHref %>\" />\n<% if (it.skinHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.skinHref %>\" />\n<% } %>\n<% if (it.stylesheetHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.stylesheetHref %>\" />\n<% } %>\n <script type=\"module\" src=\"<%= it.scriptHref %>\" nonce=\"<%= it.nonce %>\"></script>\n </head>\n <body data-skin=\"<%= it.skin %>\" data-layout=\"<%= it.layout %>\"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>\n <svg width=\"0\" height=\"0\" style=\"position: absolute\" aria-hidden=\"true\">\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"3.5\" /><path d=\"M5.5 20a6.5 6.5 0 0 1 13 0\" /></symbol>\n <symbol id=\"i-lock\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"2\" /><path d=\"M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3\" /></symbol>\n <symbol id=\"i-eye\" viewBox=\"0 0 24 24\"><path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\" /><circle cx=\"12\" cy=\"12\" r=\"2.8\" /></symbol>\n <symbol id=\"i-arrow\" viewBox=\"0 0 24 24\"><path d=\"M5 12h14M13 6l6 6-6 6\" /></symbol>\n <symbol id=\"i-key\" viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"15\" r=\"3.8\" /><path d=\"M10.8 12.2 19.5 3.5M16.5 6.5l2.5 2.5M14 9l2 2\" /></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></symbol>\n <symbol id=\"i-finger\" viewBox=\"0 0 24 24\"><path d=\"M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3\" /><path d=\"M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6\" /><path d=\"M12 11v2a14 14 0 0 0 2.5 8\" /><path d=\"M8 15a18 18 0 0 0 1.8 6\" /><path d=\"M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95\" /></symbol>\n <symbol id=\"i-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4\" /></symbol>\n <symbol id=\"i-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" /></symbol>\n <symbol id=\"i-shield\" viewBox=\"0 0 24 24\"><path d=\"M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z\" /></symbol>\n <symbol id=\"m-github\" viewBox=\"0 0 16 16\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z\" /></symbol>\n </svg>\n <header class=\"nf-strip\">\n <div class=\"nf-strip-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n </header>\n <div class=\"nf-layout\">\n <aside class=\"nf-hero\" aria-hidden=\"true\">\n <div class=\"nf-hero-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n<% if (it.hero === null) { %>\n <div class=\"nf-home\">\n <img\n class=\"nf-home-logo\"\n src=\"<%= it.logoHref %>\"\n alt=\"\"\n />\n <h2>Le framework Node.js fullstack</h2>\n <p class=\"sub\">\n Temps r\u00E9el natif \u00B7 d\u00E9veloppement agentic-ready \u00B7 socle TypeScript\n isomorphe\n </p>\n <p class=\"sig\">\n Une action de contr\u00F4leur. Deux transports. La m\u00EAme session, la m\u00EAme\n s\u00E9curit\u00E9, le m\u00EAme code.\n </p>\n <ul class=\"badges\">\n <li class=\"agentic\">Agentic ready</li>\n <li>Node.js \u2265 24.14</li>\n <li>TypeScript strict</li>\n <li>ESM only</li>\n <li>Licence Apache 2.0</li>\n </ul>\n <div\n class=\"install\"\n role=\"img\"\n aria-label=\"Commande d'installation : npx nodefony create app\"\n >\n <span class=\"p\">$</span><span>npx nodefony create app</span>\n </div>\n <div class=\"features\">\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <polygon\n points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"\n /></svg\n ></span>\n <h3>Temps r\u00E9el natif</h3>\n <p>\n Le WebSocket n'est pas un ajout : m\u00EAme pipeline, m\u00EAme table de\n routes, m\u00EAme s\u00E9curit\u00E9 que le HTTP.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\" />\n <path d=\"m9 12 2 2 4-4\" /></svg\n ></span>\n <h3>S\u00E9curit\u00E9 par conception</h3>\n <p>\n Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,\n sessions hybrides, WebAuthn.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" /></svg\n ></span>\n <h3>DI & modules</h3>\n <p>\n Injection de d\u00E9pendances par d\u00E9corateurs, modules autonomes,\n configuration valid\u00E9e au boot.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path\n d=\"M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z\"\n /></svg\n ></span>\n <h3>Socle isomorphe</h3>\n <p>\n Le m\u00EAme paquet c\u00F4t\u00E9 serveur et c\u00F4t\u00E9 navigateur : client temps\n r\u00E9el, r\u00E8gles et types \u00E9crits une fois.\n </p>\n </div>\n </div>\n </div>\n <small>Propuls\u00E9 par Nodefony</small>\n<% } else if (it.hero) { %>\n <div class=\"nf-home\">\n <h2><%= it.hero.heading %></h2>\n<% if (it.hero.text) { %>\n <p class=\"sub\"><%= it.hero.text %></p>\n<% } %>\n </div>\n<% } %>\n </aside>\n <main class=\"nf-stage\" data-nf-login data-from=\"<%= it.from %>\">\n <button type=\"button\" class=\"nf-theme\" data-theme-toggle aria-label=\"Basculer entre th\u00E8me clair et th\u00E8me sombre\"><svg class=\"nf-icon t-sun\" aria-hidden=\"true\"><use href=\"#i-sun\" /></svg><svg class=\"nf-icon t-moon\" aria-hidden=\"true\"><use href=\"#i-moon\" /></svg></button>\n <section class=\"nf-card\" aria-labelledby=\"nf-title\">\n <div class=\"nf-head\">\n <div class=\"nf-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n <h1 id=\"nf-title\"><%= it.title %></h1>\n <p class=\"nf-sub\" id=\"nf-sub\"><%= it.lead %></p>\n </div>\n <div class=\"nf-body\">\n<% if (it.password) { %>\n <ol class=\"nf-steps\" aria-label=\"\u00C9tapes\">\n <li data-tab=\"identifier\" aria-current=\"step\">Identifiant</li>\n <li data-tab=\"password\">Mot de passe</li>\n <li data-tab=\"mfa\" hidden>V\u00E9rification</li>\n </ol>\n <form data-step=\"identifier\">\n <div class=\"nf-field\">\n <label for=\"nf-username\">Identifiant ou adresse e-mail</label>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-user\" /></svg>\n <input id=\"nf-username\" name=\"username\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\" required />\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Continuer<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"password\" hidden>\n <!-- Compte de l'\u00E9tape pr\u00E9c\u00E9dente, masqu\u00E9 : le gestionnaire de mots de passe\n associe le mot de passe \u00E0 CE compte (HTML, jeton \u00AB username \u00BB). -->\n <input name=\"username\" autocomplete=\"username\" hidden readonly tabindex=\"-1\" />\n <div class=\"nf-account\">\n <span class=\"nf-avatar\" aria-hidden=\"true\"></span><span class=\"who\"></span>\n <button type=\"button\" class=\"nf-link\" data-go=\"identifier\">Changer</button>\n </div>\n <div class=\"nf-field\">\n <div class=\"nf-label-row\"><label for=\"nf-password\">Mot de passe</label></div>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-lock\" /></svg>\n <input id=\"nf-password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"button\" class=\"nf-reveal\" data-reveal aria-label=\"Afficher le mot de passe\" aria-pressed=\"false\"><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-eye\" /></svg></button>\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Se connecter<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"mfa\" hidden>\n <div class=\"nf-field\">\n <label id=\"nf-otp-label\">Code \u00E0 6 chiffres</label>\n <div class=\"nf-otp\" role=\"group\" aria-labelledby=\"nf-otp-label\">\n <input inputmode=\"numeric\" maxlength=\"1\" autocomplete=\"one-time-code\" aria-label=\"Chiffre 1\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 2\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 3\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 4\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 5\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 6\" />\n </div>\n </div>\n <p class=\"nf-hint\">Le code affich\u00E9 par votre application d'authentification.</p>\n <button class=\"nf-btn primary\" type=\"submit\">V\u00E9rifier</button>\n </form>\n<% } %>\n<% if (it.password || it.passkey) { %>\n <div data-step=\"authenticated\" hidden>\n <div class=\"nf-done\" role=\"status\">\n <span class=\"ring\" aria-hidden=\"true\"><svg class=\"nf-icon\"><use href=\"#i-check\" /></svg></span>\n <strong>Session ouverte</strong><span class=\"dest\"></span>\n <span class=\"nf-progress\" aria-hidden=\"true\"><span></span></span>\n </div>\n </div>\n<% } %>\n<% if (it.providers.length > 0 || it.passkey) { %>\n <div data-alt>\n<% if (it.password) { %>\n <div class=\"nf-or\">ou</div>\n<% } %>\n <div class=\"nf-alt\">\n<% if (it.passkey) { %>\n <button type=\"button\" class=\"nf-btn span\" data-passkey hidden><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-finger\" /></svg>Passkey / empreinte digitale</button>\n<% } %>\n<% it.providers.forEach(function (provider) { %>\n <a class=\"nf-btn\" href=\"<%= provider.href %>\"><% if (provider.image) { %><img class=\"nf-icon\" src=\"<%= provider.image %>\" alt=\"\" /><% } else { %><svg class=\"<%= provider.icon === 'm-github' ? 'nf-mark' : 'nf-icon' %>\" aria-hidden=\"true\"><use href=\"#<%= provider.icon %>\" /></svg><% } %><% if (provider.caption) { %><span class=\"nf-label\"><%= provider.label %><small><%= provider.caption %></small></span><% } else { %><%= provider.label %><% } %></a>\n<% }) %>\n </div>\n </div>\n<% } %>\n <div class=\"nf-message\" aria-live=\"polite\" data-message><% if (it.empty) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">\u2014</span><span>Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.</span></p><% } else if (it.notice) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">SSO</span><span><%= it.notice %></span></p><% } %></div>\n<% if (it.password || it.passkey) { %>\n <noscript><p class=\"nf-hint\">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>\n<% } %>\n </div>\n </section>\n<% if (it.picker) { %>\n <form class=\"nf-picker\" method=\"get\" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook=\"<%= it.picker.hook %>\"<% } %> aria-label=\"Aper\u00E7u de la page (d\u00E9veloppement)\">\n <input type=\"hidden\" name=\"from\" value=\"<%= it.from %>\" />\n <label>Habillage <select name=\"skin\"><% it.picker.skins.forEach(function (skin) { %><option value=\"<%= skin %>\"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>\n <label>Mise en page <select name=\"layout\"><% it.picker.layouts.forEach(function (layout) { %><option value=\"<%= layout %>\"<% if (layout === it.layout) { %> selected<% } %>><%= layout %></option><% }) %></select></label>\n <noscript><button type=\"submit\">Appliquer</button></noscript>\n </form>\n<% } %>\n<% if (it.footer) { %>\n <div class=\"nf-foot\">\n <ul class=\"nf-facts\" aria-label=\"Protection de la session\">\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>cookie HttpOnly</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>anti-CSRF</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>CSP stricte</li>\n </ul>\n <a class=\"nf-powered\" href=\"https://github.com/nodefony/nodefony-core\" rel=\"noopener noreferrer\"><img class=\"nf-logo nf-logo-mono\" src=\"<%= it.logoHref %>\" alt=\"\" />Propuls\u00E9 par Nodefony</a>\n </div>\n<% } %>\n </main>\n </div>\n </body>\n</html>\n";
144
+ export declare const LOGIN_PAGE_TEMPLATE = "<!doctype html>\n<html lang=\"fr\"<% if (it.theme) { %> data-theme=\"<%= it.theme %>\"<% } %>>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"color-scheme\" content=\"dark light\" />\n <meta name=\"robots\" content=\"noindex\" />\n <% if (it.nonce) { %><meta property=\"csp-nonce\" nonce=\"<%= it.nonce %>\"><% } %>\n <title><%= it.title %> \u2014 <%= it.brand %></title>\n <link rel=\"icon\" href=\"<%= it.logoHref %>\" type=\"image/svg+xml\" />\n<% if (!it.theme) { %>\n <script nonce=\"<%= it.nonce %>\">try{var t=localStorage.getItem(\"nf-login-theme\");if(t===\"light\"||t===\"dark\")document.documentElement.dataset.theme=t}catch(e){}</script>\n<% } %>\n <link rel=\"stylesheet\" href=\"<%= it.styleHref %>\" />\n<% if (it.skinHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.skinHref %>\" />\n<% } %>\n<% if (it.stylesheetHref) { %>\n <link rel=\"stylesheet\" href=\"<%= it.stylesheetHref %>\" />\n<% } %>\n <script type=\"module\" src=\"<%= it.scriptHref %>\" nonce=\"<%= it.nonce %>\"></script>\n </head>\n <body data-skin=\"<%= it.skin %>\" data-layout=\"<%= it.layout %>\"<% if (it.backdrop) { %> data-backdrop<% } %><% if (it.ssoFirst) { %> data-sso-first<% } %>>\n <svg width=\"0\" height=\"0\" style=\"position: absolute\" aria-hidden=\"true\">\n <symbol id=\"i-user\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"8\" r=\"3.5\" /><path d=\"M5.5 20a6.5 6.5 0 0 1 13 0\" /></symbol>\n <symbol id=\"i-lock\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"10.5\" width=\"14\" height=\"10\" rx=\"2\" /><path d=\"M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3\" /></symbol>\n <symbol id=\"i-eye\" viewBox=\"0 0 24 24\"><path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\" /><circle cx=\"12\" cy=\"12\" r=\"2.8\" /></symbol>\n <symbol id=\"i-arrow\" viewBox=\"0 0 24 24\"><path d=\"M5 12h14M13 6l6 6-6 6\" /></symbol>\n <symbol id=\"i-key\" viewBox=\"0 0 24 24\"><circle cx=\"8\" cy=\"15\" r=\"3.8\" /><path d=\"M10.8 12.2 19.5 3.5M16.5 6.5l2.5 2.5M14 9l2 2\" /></symbol>\n <symbol id=\"i-check\" viewBox=\"0 0 24 24\"><path d=\"M5 12.5l4.5 4.5L19 7.5\" /></symbol>\n <symbol id=\"i-finger\" viewBox=\"0 0 24 24\"><path d=\"M18.9 7a8 8 0 0 1 1.1 5v1a6 6 0 0 0 .8 3\" /><path d=\"M8 11a4 4 0 0 1 8 0v1a10 10 0 0 0 2 6\" /><path d=\"M12 11v2a14 14 0 0 0 2.5 8\" /><path d=\"M8 15a18 18 0 0 0 1.8 6\" /><path d=\"M4.9 19a22 22 0 0 1-.9-7v-1a8 8 0 0 1 12-6.95\" /></symbol>\n <symbol id=\"i-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4\" /></symbol>\n <symbol id=\"i-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z\" /></symbol>\n <symbol id=\"i-shield\" viewBox=\"0 0 24 24\"><path d=\"M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z\" /></symbol>\n <symbol id=\"m-github\" viewBox=\"0 0 16 16\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z\" /></symbol>\n </svg>\n <header class=\"nf-strip\">\n <div class=\"nf-strip-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n </header>\n <div class=\"nf-layout\">\n <aside class=\"nf-hero\" aria-hidden=\"true\">\n <div class=\"nf-hero-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n<% if (it.hero === null) { %>\n <div class=\"nf-home\">\n <img\n class=\"nf-home-logo\"\n src=\"<%= it.logoHref %>\"\n alt=\"\"\n />\n <h2>Le framework Node.js fullstack</h2>\n <p class=\"sub\">\n Temps r\u00E9el natif \u00B7 d\u00E9veloppement agentic-ready \u00B7 socle TypeScript\n isomorphe\n </p>\n <p class=\"sig\">\n Une action de contr\u00F4leur. Deux transports. La m\u00EAme session, la m\u00EAme\n s\u00E9curit\u00E9, le m\u00EAme code.\n </p>\n <ul class=\"badges\">\n <li class=\"agentic\">Agentic ready</li>\n <li>Node.js 24.16+ ou 26+</li>\n <li>TypeScript strict</li>\n <li>ESM only</li>\n <li>Licence Apache 2.0</li>\n </ul>\n <div\n class=\"install\"\n role=\"img\"\n aria-label=\"Commande d'installation : npx nodefony create app\"\n >\n <span class=\"p\">$</span><span>npx nodefony create app</span>\n </div>\n <div class=\"features\">\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <polygon\n points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"\n /></svg\n ></span>\n <h3>Temps r\u00E9el natif</h3>\n <p>\n Le WebSocket n'est pas un ajout : m\u00EAme pipeline, m\u00EAme table de\n routes, m\u00EAme s\u00E9curit\u00E9 que le HTTP.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\" />\n <path d=\"m9 12 2 2 4-4\" /></svg\n ></span>\n <h3>S\u00E9curit\u00E9 par conception</h3>\n <p>\n Pare-feu applicatif par zones, Zero Trust, CSRF Fetch Metadata,\n sessions hybrides, WebAuthn.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" /></svg\n ></span>\n <h3>DI & modules</h3>\n <p>\n Injection de d\u00E9pendances par d\u00E9corateurs, modules autonomes,\n configuration valid\u00E9e au boot.\n </p>\n </div>\n <div class=\"feature\">\n <span class=\"ic\"\n ><svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path\n d=\"M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z\"\n /></svg\n ></span>\n <h3>Socle isomorphe</h3>\n <p>\n Le m\u00EAme paquet c\u00F4t\u00E9 serveur et c\u00F4t\u00E9 navigateur : client temps\n r\u00E9el, r\u00E8gles et types \u00E9crits une fois.\n </p>\n </div>\n </div>\n </div>\n <small>Propuls\u00E9 par Nodefony</small>\n<% } else if (it.hero) { %>\n <div class=\"nf-home\">\n <h2><%= it.hero.heading %></h2>\n<% if (it.hero.text) { %>\n <p class=\"sub\"><%= it.hero.text %></p>\n<% } %>\n </div>\n<% } %>\n </aside>\n <main class=\"nf-stage\" data-nf-login data-from=\"<%= it.from %>\">\n <button type=\"button\" class=\"nf-theme\" data-theme-toggle aria-label=\"Basculer entre th\u00E8me clair et th\u00E8me sombre\"><svg class=\"nf-icon t-sun\" aria-hidden=\"true\"><use href=\"#i-sun\" /></svg><svg class=\"nf-icon t-moon\" aria-hidden=\"true\"><use href=\"#i-moon\" /></svg></button>\n <section class=\"nf-card\" aria-labelledby=\"nf-title\">\n <div class=\"nf-head\">\n <div class=\"nf-brand\"><img class=\"nf-logo\" src=\"<%= it.logo %>\" alt=\"\" /><%= it.brand %></div>\n <h1 id=\"nf-title\"><%= it.title %></h1>\n <p class=\"nf-sub\" id=\"nf-sub\"><%= it.lead %></p>\n </div>\n <div class=\"nf-body\">\n<% if (it.password) { %>\n <ol class=\"nf-steps\" aria-label=\"\u00C9tapes\">\n <li data-tab=\"identifier\" aria-current=\"step\">Identifiant</li>\n <li data-tab=\"password\">Mot de passe</li>\n <li data-tab=\"mfa\" hidden>V\u00E9rification</li>\n </ol>\n <form data-step=\"identifier\">\n <div class=\"nf-field\">\n <label for=\"nf-username\">Identifiant ou adresse e-mail</label>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-user\" /></svg>\n <input id=\"nf-username\" name=\"username\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\" required />\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Continuer<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"password\" hidden>\n <!-- Compte de l'\u00E9tape pr\u00E9c\u00E9dente, masqu\u00E9 : le gestionnaire de mots de passe\n associe le mot de passe \u00E0 CE compte (HTML, jeton \u00AB username \u00BB). -->\n <input name=\"username\" autocomplete=\"username\" hidden readonly tabindex=\"-1\" />\n <div class=\"nf-account\">\n <span class=\"nf-avatar\" aria-hidden=\"true\"></span><span class=\"who\"></span>\n <button type=\"button\" class=\"nf-link\" data-go=\"identifier\">Changer</button>\n </div>\n <div class=\"nf-field\">\n <div class=\"nf-label-row\"><label for=\"nf-password\">Mot de passe</label></div>\n <div class=\"nf-input\">\n <svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-lock\" /></svg>\n <input id=\"nf-password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required />\n <button type=\"button\" class=\"nf-reveal\" data-reveal aria-label=\"Afficher le mot de passe\" aria-pressed=\"false\"><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-eye\" /></svg></button>\n </div>\n </div>\n <button class=\"nf-btn primary\" type=\"submit\">Se connecter<svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-arrow\" /></svg></button>\n </form>\n <form data-step=\"mfa\" hidden>\n <div class=\"nf-field\">\n <label id=\"nf-otp-label\">Code \u00E0 6 chiffres</label>\n <div class=\"nf-otp\" role=\"group\" aria-labelledby=\"nf-otp-label\">\n <input inputmode=\"numeric\" maxlength=\"1\" autocomplete=\"one-time-code\" aria-label=\"Chiffre 1\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 2\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 3\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 4\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 5\" /><input inputmode=\"numeric\" maxlength=\"1\" aria-label=\"Chiffre 6\" />\n </div>\n </div>\n <p class=\"nf-hint\">Le code affich\u00E9 par votre application d'authentification.</p>\n <button class=\"nf-btn primary\" type=\"submit\">V\u00E9rifier</button>\n </form>\n<% } %>\n<% if (it.password || it.passkey) { %>\n <div data-step=\"authenticated\" hidden>\n <div class=\"nf-done\" role=\"status\">\n <span class=\"ring\" aria-hidden=\"true\"><svg class=\"nf-icon\"><use href=\"#i-check\" /></svg></span>\n <strong>Session ouverte</strong><span class=\"dest\"></span>\n <span class=\"nf-progress\" aria-hidden=\"true\"><span></span></span>\n </div>\n </div>\n<% } %>\n<% if (it.providers.length > 0 || it.passkey) { %>\n <div data-alt>\n<% if (it.password) { %>\n <div class=\"nf-or\">ou</div>\n<% } %>\n <div class=\"nf-alt\">\n<% if (it.passkey) { %>\n <button type=\"button\" class=\"nf-btn span\" data-passkey hidden><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-finger\" /></svg>Passkey / empreinte digitale</button>\n<% } %>\n<% it.providers.forEach(function (provider) { %>\n <a class=\"nf-btn\" href=\"<%= provider.href %>\"><% if (provider.image) { %><img class=\"nf-icon\" src=\"<%= provider.image %>\" alt=\"\" /><% } else { %><svg class=\"<%= provider.icon === 'm-github' ? 'nf-mark' : 'nf-icon' %>\" aria-hidden=\"true\"><use href=\"#<%= provider.icon %>\" /></svg><% } %><% if (provider.caption) { %><span class=\"nf-label\"><%= provider.label %><small><%= provider.caption %></small></span><% } else { %><%= provider.label %><% } %></a>\n<% }) %>\n </div>\n </div>\n<% } %>\n <div class=\"nf-message\" aria-live=\"polite\" data-message><% if (it.empty) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">\u2014</span><span>Aucun moyen de connexion n'est disponible pour le moment. Pr\u00E9venez l'administrateur du site.</span></p><% } else if (it.notice) { %><p class=\"nf-alert\" role=\"alert\"><span class=\"code\">SSO</span><span><%= it.notice %></span></p><% } %></div>\n<% if (it.password || it.passkey) { %>\n <noscript><p class=\"nf-hint\">Cette page a besoin de JavaScript pour vous connecter.</p></noscript>\n<% } %>\n </div>\n </section>\n<% if (it.picker) { %>\n <form class=\"nf-picker\" method=\"get\" data-preview-picker<% if (it.picker.hook) { %> data-preview-hook=\"<%= it.picker.hook %>\"<% } %> aria-label=\"Aper\u00E7u de la page (d\u00E9veloppement)\">\n <input type=\"hidden\" name=\"from\" value=\"<%= it.from %>\" />\n <label>Habillage <select name=\"skin\"><% it.picker.skins.forEach(function (skin) { %><option value=\"<%= skin %>\"<% if (skin === it.skin) { %> selected<% } %>><%= skin %></option><% }) %></select></label>\n <label>Mise en page <select name=\"layout\"><% it.picker.layouts.forEach(function (layout) { %><option value=\"<%= layout %>\"<% if (layout === it.layout) { %> selected<% } %>><%= layout %></option><% }) %></select></label>\n <noscript><button type=\"submit\">Appliquer</button></noscript>\n </form>\n<% } %>\n<% if (it.footer) { %>\n <div class=\"nf-foot\">\n <ul class=\"nf-facts\" aria-label=\"Protection de la session\">\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>cookie HttpOnly</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>anti-CSRF</li>\n <li><svg class=\"nf-icon\" aria-hidden=\"true\"><use href=\"#i-shield\" /></svg>CSP stricte</li>\n </ul>\n <a class=\"nf-powered\" href=\"https://github.com/nodefony/nodefony-core\" rel=\"noopener noreferrer\"><img class=\"nf-logo nf-logo-mono\" src=\"<%= it.logoHref %>\" alt=\"\" />Propuls\u00E9 par Nodefony</a>\n </div>\n<% } %>\n </main>\n </div>\n </body>\n</html>\n";
@@ -404,20 +404,24 @@ class MeController extends Controller {
404
404
  ## 🧰 Répondre — ce que ton `return` déclenche
405
405
 
406
406
  Le traducteur unique est `Resolver.returnController()` (`Resolver.ts#Resolver.returnController`). Il regarde le **type**
407
- de ce que tu as retourné :
407
+ de ce que tu as retourné — dans l'ordre de fréquence : la promesse, puis le tableau et l'objet simple,
408
+ le cas courant, qui ne paient aucun test de plus que nécessaire (`Resolver.ts#Resolver.returnObject`) :
408
409
 
409
410
  <!-- prettier-ignore -->
410
411
  | Tu retournes… | Ce qui se passe | Ancre |
411
412
  | --- | --- | --- |
412
- | Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts#Resolver._areaSecurity` |
413
- | Une `string` | Envoyée telle quelle en corps | `Resolver.ts#Resolver._callWithIdempotency` |
414
- | Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts#Resolver.returnController` |
415
- | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts#Resolver._callWithIdempotency` |
416
- | Un `Buffer` | Envoyé brut | `Resolver.ts#Resolver.returnController` |
417
- | Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts#Resolver._invokeAction` |
413
+ | Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts#Resolver.returnController` |
414
+ | Une `string` | Envoyée telle quelle en corps | `Resolver.ts#Resolver.returnController` |
415
+ | Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts#Resolver.returnObject` |
416
+ | Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts#Resolver.returnController` |
417
+ | Un `Buffer` | Envoyé brut | `Resolver.ts#Resolver.returnObject` |
418
+ | Une réponse Nodefony (rendue par un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts#Resolver.returnObject` |
419
+ | Une **`Response` Web standard** — `fetch()`, `new Response(…)`, `Response.json(…)` | **Envoyée en flux** : son statut, ses en-têtes, son corps (HTTP seulement) — détail ci-dessous | `HttpContext.openWebResponse()` (`HttpContext.ts#HttpContext.openWebResponse`, `@nodefony/http`) |
420
+ | Un **flux d'octets nu** — `Readable` Node, `ReadableStream` Web | **Envoyé en flux**, comme `this.renderStream(source)` sans option (HTTP seulement) | `Resolver.ts#Resolver.returnObject` |
418
421
  | `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `isContentlessStatus` (`Response.ts#isContentlessStatus`, `@nodefony/http`) |
419
- | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts#Resolver.returnController` |
420
- | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts#Resolver.returnController` |
422
+ | `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts#Resolver.returnNothing` |
423
+ | Une `Date`, une `RegExp`, une `Error` | Comme `void` : rien à rendre | `Resolver.ts#Resolver.returnNothing` |
424
+ | Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts#Resolver.returnObject` |
421
425
 
422
426
  > [!WARNING]
423
427
  > **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
@@ -429,6 +433,82 @@ de ce que tu as retourné :
429
433
  Même règle pour une **instance de classe** (une entité ORM renvoyée telle quelle) : elle n'est
430
434
  volontairement pas passée à `JSON.stringify`. Retourne un objet simple — ou appelle `renderJson()`.
431
435
 
436
+ ### Renvoyer une `Response` Web ou un flux
437
+
438
+ La `Response` est l'objet standard que rend `fetch()` — dans le navigateur, et dans Node depuis la
439
+ version 18. Beaucoup de bibliothèques en produisent une toute prête (un kit d'IA qui rend son texte
440
+ en flux, un client d'API). Ton action peut la **renvoyer telle quelle** : le framework envoie son
441
+ statut, ses en-têtes et son corps EN FLUX, sans le lire d'avance.
442
+
443
+ ```ts
444
+ import { Readable } from "node:stream";
445
+ import { Controller, controller, route } from "@nodefony/framework";
446
+ import type { Context } from "@nodefony/http";
447
+
448
+ @controller("/examples")
449
+ export class ExamplesController extends Controller {
450
+ constructor(context: Context) {
451
+ super("ExamplesController", context);
452
+ }
453
+
454
+ /** Relayer une API tierce : sa réponse, telle quelle. */
455
+ @route("weather", { path: "/weather" })
456
+ weather() {
457
+ // Le signal coupe l'appel amont si le client s'en va.
458
+ return fetch("https://api.exemple.org/meteo", {
459
+ signal: this.context?.signal ?? null,
460
+ });
461
+ }
462
+
463
+ /** Une réponse construite : statut, en-têtes et cookie à toi. */
464
+ @route("export", { path: "/export.csv" })
465
+ export() {
466
+ return new Response("id;libellé\n1;Cordage\n", {
467
+ status: 200,
468
+ headers: [
469
+ ["content-type", "text/csv; charset=utf-8"],
470
+ ["content-disposition", 'attachment; filename="export.csv"'],
471
+ ["set-cookie", "export=fait; Path=/; HttpOnly"],
472
+ ],
473
+ });
474
+ }
475
+
476
+ /** Un flux d'octets renvoyé nu. */
477
+ @route("lines", { path: "/lines" })
478
+ lines() {
479
+ return Readable.from(["une\n", "deux\n"], { objectMode: false });
480
+ }
481
+ }
482
+ ```
483
+
484
+ Ce que le framework fait de la réponse (`webResponse.ts#webResponseParts`, `@nodefony/http`) :
485
+
486
+ | Origine de la `Response` | En-têtes envoyés |
487
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
488
+ | **Construite** par ton code (`new Response`, `Response.json`, `Response.redirect`) | tous les siens, **cookies compris** |
489
+ | **Relayée** — rendue par `fetch()` | les siens, **sauf** `content-encoding` et `content-length` (`fetch` a déjà décompressé le corps : ils décriraient des octets qui ne sont plus là) et `set-cookie` (les cookies d'un service tiers seraient posés sous TON domaine) |
490
+
491
+ Dans les deux cas, les en-têtes de connexion (`connection`, `keep-alive`, `transfer-encoding`,
492
+ `upgrade`… — RFC 9110 §7.6.1) ne partent jamais : ils décrivent un saut, et HTTP/2 les refuse.
493
+ La différence se lit sur `response.type` : `"default"` pour une réponse construite, `"basic"` ou
494
+ `"cors"` pour une réponse de `fetch`.
495
+
496
+ > [!TIP]
497
+ > **Quand préférer `this.renderStream()`** : dès qu'il faut des options — délai de silence
498
+ > (`idleTimeout`), durée maximale (`maxDuration`), écouteur de fin (`onClose`), ou ne recopier que
499
+ > certains en-têtes de l'amont. `return response` est le geste court du cas courant ;
500
+ > `renderStream` est le réglage fin. Les deux passent par la même tuyauterie
501
+ > (`HttpContext.ts#HttpContext.openStream`) : contre-pression, arrêt au départ du client, `HEAD` sans
502
+ > lire le corps.
503
+
504
+ > [!WARNING]
505
+ >
506
+ > - **`Response.error()`** ne porte aucune réponse (statut 0) : le client reçoit un **502**.
507
+ > - **WebSocket** : une `Response` ou un flux renvoyé par une action de message n'est pas envoyé — une
508
+ > trame n'a ni statut ni en-têtes. Renvoie un objet (envoyé en trame JSON) ou un `Buffer`.
509
+ > - **Réponse déjà partie** (tu as appelé `render*` ou `streamFile` ET renvoyé une `Response`) : rien
510
+ > n'est réécrit, et le corps de la `Response` est annulé pour libérer l'amont.
511
+
432
512
  ### Les helpers de rendu
433
513
 
434
514
  Quand tu veux piloter l'envoi plutôt que retourner une valeur :