@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
@@ -1,7 +1,7 @@
1
1
  import Route from "../src/Route.js";
2
2
  import Resolver from "../src/Resolver.js";
3
3
  import { assertRouteSchemaDeclaration, routeExpectsBodyStream } from "../decorators/routerDecorators.js";
4
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
4
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
5
5
  import { BootConfigurationError, Injector, Module, Service, injectable, isPromise, stripTrailingSlashes } from "nodefony";
6
6
  import { HttpError, isDomainAllowed } from "@nodefony/http";
7
7
  // nodefony/service/router.ts
@@ -173,10 +173,11 @@ let Router = class Router extends Service {
173
173
  * lieu de `context.request.url` — permet de router un path **porté par un message**
174
174
  * (WS-RPC `invoke`) vers une action, sans muter l'URL de la connexion (état partagé).
175
175
  * `undefined` (cas hot path normal) → comportement inchangé.
176
- * @param methodOverride - méthode HTTP **logique** à exiger en plus du transport
177
- * WEBSOCKET (pont WS-RPC `api.request` d'une MUTATION) : lève l'ambiguïté
178
- * GET-via-WS / POST-via-WS sur un même chemin (`context.method` = "WEBSOCKET").
179
- * `undefined` (GET/HTTP) → match historique sur `context.method`.
176
+ * @param methodOverride - méthode HTTP **logique** du pont WS-RPC `api.request`,
177
+ * GET compris, exigée en plus du transport WEBSOCKET par la règle du cœur
178
+ * `bridgeServesVerb` : un GET n'atteint jamais une action POST du même
179
+ * chemin. `undefined` (requête HTTP, ouverture de socket) → match sur
180
+ * `context.method`.
180
181
  * @returns un `Resolver` (`.resolve === true` si une route a matché) — ou,
181
182
  * pour un chemin servi en WebSocket SEULEMENT et demandé en HTTP, un
182
183
  * `Resolver` dont `exception` porte le `426` (en-têtes `Upgrade` posés) :
@@ -220,8 +221,9 @@ let Router = class Router extends Service {
220
221
  return resolver;
221
222
  }
222
223
  } catch (e) {
223
- this.log(`Match route exception : ${route.name} ${String(e)}`, "DEBUG");
224
- resolver.exception = e;
224
+ routeMatchLogged ??= this.kernel?.environment !== "production";
225
+ if (routeMatchLogged) this.log(`Match route exception : ${route.name} ${String(e)}`, "DEBUG");
226
+ resolver.exception = e instanceof Error ? e : new Error(String(e));
225
227
  continue;
226
228
  }
227
229
  }
@@ -239,12 +241,12 @@ let Router = class Router extends Service {
239
241
  Upgrade: "websocket",
240
242
  Connection: "Upgrade"
241
243
  });
242
- resolver.exception = new HttpError("Upgrade Required: websocket", 426, context);
244
+ resolver.exception = HttpError.withoutStack("Upgrade Required: websocket", 426, context);
243
245
  return resolver;
244
246
  }
245
247
  if (allowed.size > 0) {
246
248
  const allowHeader = Array.from(allowed).join(", ");
247
- const err = new HttpError(`Method ${context.method} Not Allowed`, 405, context);
249
+ const err = HttpError.withoutStack(`Method ${context.method} Not Allowed`, 405, context);
248
250
  const methodErr = err;
249
251
  methodErr.allow = allowHeader;
250
252
  methodErr.type = "method";
@@ -4,8 +4,8 @@ import { evaluatePreconditions, fileValidators, ifRangeHolds, parseHttpDate } fr
4
4
  import { VIEW_NONCE_LOCAL, signTemplateTags } from "./viewNonce.js";
5
5
  import { FileClass, RequestContext, Service } from "nodefony";
6
6
  import { HttpContext, HttpError, acceptsEventStream } from "@nodefony/http";
7
- import { createReadStream } from "node:fs";
8
7
  import { Readable } from "node:stream";
8
+ import { createReadStream } from "node:fs";
9
9
  // nodefony/src/Controller.ts
10
10
  /**
11
11
  * Un `<script>` ou un `<style>` qui ne porte pas d'attribut `nonce`.
@@ -131,10 +131,17 @@ function encodeExtValue(value) {
131
131
  * hors ASCII imprimable, `"`, `\` et `%` — un nom accentué ne lève donc
132
132
  * jamais `ERR_INVALID_CHAR`, et un guillemet ne casse pas la valeur.
133
133
  *
134
+ * Exportée pour servir un fichier sous un AUTRE nom que celui du disque —
135
+ * un fichier reçu, rangé sous un identifiant tiré par le serveur, se rend
136
+ * sous le nom que le client lui a donné, passé en en-tête de
137
+ * `renderFileDownload()` ou de `streamFile()`.
138
+ *
134
139
  * @param type - `attachment` (téléchargement) ou `inline` (affiché).
135
140
  * @param name - le nom du fichier, tel quel.
136
141
  * @returns la valeur de l'en-tête.
137
142
  */
143
+ /** Aucun fichier reçu : une requête sans corps multipart, ou une socket. */
144
+ const NO_FILES = Object.freeze([]);
138
145
  function contentDisposition(type, name) {
139
146
  const fallback = name.replace(/[^\x20-\x7e]|["\\%]/g, "_");
140
147
  if (fallback === name) return `${type}; filename="${name}"`;
@@ -272,7 +279,7 @@ var Controller = class extends Service {
272
279
  * préférer `@UploadedFiles()`.
273
280
  */
274
281
  get files() {
275
- return this.#files ?? (this.context?.request)?.files;
282
+ return this.#files ?? (this.context?.request)?.files ?? NO_FILES;
276
283
  }
277
284
  set files(value) {
278
285
  this.#assertPerRequest("files");
@@ -532,6 +539,17 @@ var Controller = class extends Service {
532
539
  * });
533
540
  * ```
534
541
  *
542
+ * HEAD sans lancer le producteur — toute route GET sert HEAD, la source n'y
543
+ * est jamais lue. `Readable.from([])` seul est en mode OBJET, donc refusé
544
+ * (500) : le mode octets se demande.
545
+ * ```ts
546
+ * if (this.method === "HEAD") {
547
+ * return this.renderStream(Readable.from([], { objectMode: false }), {
548
+ * headers: { "content-type": "video/mp4" },
549
+ * });
550
+ * }
551
+ * ```
552
+ *
535
553
  * @param source - flux Node, ou flux web ; jamais en mode objet.
536
554
  * @param options - statut, en-têtes, `idleTimeout`, `stallTimeout`,
537
555
  * `maxDuration`, `onClose(reason)`.
@@ -853,4 +871,4 @@ var Controller = class extends Service {
853
871
  return this.#sendFile(File, head, value);
854
872
  }
855
873
  };
856
- export { configureFileServing, Controller as default, parseByteRange };
874
+ export { configureFileServing, contentDisposition, Controller as default, parseByteRange };
@@ -3,9 +3,13 @@ import { validateRouteInput } from "./routeSchema.js";
3
3
  import { buildParamArgs, computeActionMeta, readInvocationBody, readRouteInput, resolveActionMeta, resolveSessionIntent } from "../decorators/routerDecorators.js";
4
4
  import { computeFingerprint, evaluateIdempotency, isMutationMethod, resolveIdempotencyKey, resolveIdentity } from "./idempotency.js";
5
5
  import { authenticationShortfall, stepUpChallenge } from "./requireAuthentication.js";
6
- import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe, typeOf } from "nodefony";
6
+ import { RequestContext, finallyMaybe, identityHint, isArray, isPlainObject, isPromise, nodefonyError, thenMaybe } from "nodefony";
7
7
  import { Http2Response, HttpResponse, WebsocketResponse, isContentlessStatus } from "@nodefony/http";
8
+ import { Buffer } from "node:buffer";
9
+ import { Readable } from "node:stream";
8
10
  // nodefony/src/Resolver.ts
11
+ /** Une annulation qui échoue (flux déjà fermé) n'a plus personne à prévenir. */
12
+ const IGNORE_CANCEL_ERROR = () => void 0;
9
13
  /**
10
14
  * Résout une route vers son couple controller/action et exécute l'action —
11
15
  * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
@@ -56,12 +60,12 @@ var Resolver = class {
56
60
  */
57
61
  queryOverride = null;
58
62
  /**
59
- * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`
60
- * quand c'est une MUTATION (POST/PUT/PATCH/DELETE). Posée par
61
- * `Router.resolve(ctx, cleanPath, methodOverride)` → consommée par `match()`
62
- * pour désambiguïser, sur le transport WEBSOCKET unique, la route logique
63
- * visée (cf `Route.matchRequirements`). `null` (GET/HTTP) = match historique
64
- * sur `context.method`.
63
+ * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`,
64
+ * GET compris. Posée par `Router.resolve(ctx, cleanPath, methodOverride)` →
65
+ * consommée par `match()` pour désambiguïser, sur le transport WEBSOCKET
66
+ * unique, la route logique visée (règle `bridgeServesVerb` du cœur, cf
67
+ * `Route.matchRequirements`). `null` (requête HTTP, ouverture de socket) =
68
+ * match sur `context.method`.
65
69
  */
66
70
  methodOverride = null;
67
71
  /**
@@ -431,6 +435,8 @@ var Resolver = class {
431
435
  async _callWithIdempotency(meta, data, reload = false) {
432
436
  const context = this.context;
433
437
  if (!isMutationMethod(this.methodOverride ?? context.method)) return this.executeAction(data, reload, meta);
438
+ if (this.preAuthorized) this.preAuthorized = false;
439
+ else await this._enforceActionSecurity(meta);
434
440
  const als = RequestContext.get();
435
441
  const httpReq = context.request;
436
442
  const params = this._zipRouteParams();
@@ -463,6 +469,7 @@ var Resolver = class {
463
469
  redirectMeta: void 0
464
470
  };
465
471
  }
472
+ this.preAuthorized = true;
466
473
  if (verdict.kind === "execute") return this.executeAction(data, reload, meta);
467
474
  try {
468
475
  const { result, redirectMeta } = await this.executeAction(data, reload, meta);
@@ -631,7 +638,15 @@ var Resolver = class {
631
638
  /**
632
639
  * Rend la valeur d'une action sur le transport, selon sa nature : promesse
633
640
  * (démêlée puis re-rendue), texte, Buffer, réponse déjà construite, scalaire
634
- * ou objet (auto-JSON), rien (l'action envoie elle-même).
641
+ * ou objet (auto-JSON), rien (l'action envoie elle-même) — et, en HTTP, une
642
+ * `Response` Web standard (`fetch()`, `new Response(…)`, `Response.json(…)`)
643
+ * ou un flux d'octets nu (`Readable` Node, `ReadableStream` Web), envoyés EN
644
+ * FLUX par `HttpContext.openWebResponse` / `HttpContext.openStream`.
645
+ *
646
+ * Ces deux-là sont reconnus APRÈS l'objet simple et le tableau : l'auto-JSON,
647
+ * le cas courant, ne paie aucun test de plus. Une réponse déjà partie
648
+ * (`sended`) n'est jamais réécrite ; une `Response` renvoyée en plus voit
649
+ * alors son corps annulé, pour ne pas retenir l'amont.
635
650
  *
636
651
  * Pas `async` : l'envelopper dans une promesse de plus ne changeait rien au
637
652
  * rendu, et coûtait une suspension à chaque requête (#505). La valeur n'est
@@ -643,45 +658,70 @@ var Resolver = class {
643
658
  * @throws de façon SYNCHRONE ce que lève un envoi synchrone.
644
659
  */
645
660
  returnController(result) {
646
- const type = typeOf(result);
647
- switch (true) {
648
- case result instanceof Promise:
649
- case isPromise(result): return result.then((myresult) => this.returnController(myresult));
650
- case type === "string":
651
- case result instanceof String: return this.context.send(result);
652
- case result instanceof Http2Response:
653
- case result instanceof HttpResponse:
654
- case result instanceof WebsocketResponse: return result;
655
- case type === "buffer":
656
- if (this.context.sended) return;
657
- return this.context.send(result);
658
- case type === "number":
659
- case type === "boolean":
661
+ if (isPromise(result)) return result.then((myresult) => this.returnController(myresult));
662
+ switch (typeof result) {
663
+ case "object":
664
+ if (result === null) break;
665
+ return this.returnObject(result);
666
+ case "string": return this.context.send(result);
667
+ case "number":
668
+ case "boolean":
660
669
  if (this.context.sended) return;
661
670
  this.context.setContextJson();
662
671
  return this.context.render(result);
663
- case type === "array":
664
- case type === "object":
665
- if (this.context.sended) return;
666
- if (isPlainObject(result) || isArray(result)) {
667
- this.context.setContextJson();
668
- return this.context.render(result);
669
- }
670
- switch (this.context.type) {
671
- case "http":
672
- this.context.waitAsync = true;
673
- this.unrendered = `une instance de ${(typeof result === "object" && result !== null ? result.constructor.name : void 0) ?? "classe"}`;
674
- }
675
- return;
676
- default: switch (this.context.type) {
677
- case "http":
678
- if (this.context.sended) return;
679
- if (this.context.isRedirect) return this.context.send();
680
- if (isContentlessStatus(this.getResponseStatus())) return this.context.send();
681
- this.context.waitAsync = true;
682
- this.unrendered = result === null ? "null" : typeof result;
683
- }
684
672
  }
673
+ return this.returnNothing(result);
674
+ }
675
+ /**
676
+ * Aiguille une valeur OBJET rendue par l'action — dans l'ordre de fréquence :
677
+ * le tableau et l'objet simple (auto-JSON) d'abord, les instances rares
678
+ * ensuite. Partie de {@link Resolver.returnController}.
679
+ *
680
+ * @param result - un objet non nul.
681
+ * @returns ce que rend l'envoi, ou `undefined` quand il n'y a rien à envoyer.
682
+ */
683
+ returnObject(result) {
684
+ const context = this.context;
685
+ if (isArray(result) || isPlainObject(result)) {
686
+ if (context.sended) return;
687
+ context.setContextJson();
688
+ return context.render(result);
689
+ }
690
+ if (Buffer.isBuffer(result)) {
691
+ if (context.sended) return;
692
+ return context.send(result);
693
+ }
694
+ if (result instanceof String) return context.send(result.valueOf());
695
+ if (result instanceof HttpResponse || result instanceof Http2Response || result instanceof WebsocketResponse) return result;
696
+ if (context.sended) {
697
+ if (result instanceof Response) result.body?.cancel().catch(IGNORE_CANCEL_ERROR);
698
+ return;
699
+ }
700
+ if (result instanceof Date || result instanceof RegExp || result instanceof Error) return this.returnNothing(result);
701
+ if (context.type === "http") {
702
+ if (result instanceof Response) return context.openWebResponse(result);
703
+ if (result instanceof Readable || result instanceof ReadableStream) return context.openStream(result);
704
+ context.waitAsync = true;
705
+ this.unrendered = `une instance de ${result.constructor.name || "classe"}`;
706
+ }
707
+ }
708
+ /**
709
+ * Ce qui arrive quand l'action ne rend RIEN d'envoyable (`undefined`,
710
+ * `null`, une fonction…) : en HTTP, la redirection posée ou le statut sans
711
+ * corps part ; sinon la requête est marquée non rendue (`waitAsync`). Partie
712
+ * de {@link Resolver.returnController}.
713
+ *
714
+ * @param result - la valeur rendue par l'action.
715
+ * @returns l'envoi d'une redirection ou d'un statut sans corps, sinon `undefined`.
716
+ */
717
+ returnNothing(result) {
718
+ if (this.context.type !== "http") return;
719
+ const context = this.context;
720
+ if (context.sended) return;
721
+ if (context.isRedirect) return context.send();
722
+ if (isContentlessStatus(this.getResponseStatus())) return context.send();
723
+ context.waitAsync = true;
724
+ this.unrendered = result === null ? "null" : typeof result;
685
725
  }
686
726
  /** Statut courant de la réponse (0 si le transport n'en porte pas). */
687
727
  getResponseStatus() {
@@ -1,4 +1,4 @@
1
- import { escapeRegExp, stripTrailingSlashes, typeOf } from "nodefony";
1
+ import { bridgeServesVerb, escapeRegExp, stripTrailingSlashes, typeOf } from "nodefony";
2
2
  import { HttpError, compileDomainPatterns, isDomainAllowed } from "@nodefony/http";
3
3
  import { createHash } from "node:crypto";
4
4
  // nodefony/src/Route.ts
@@ -125,6 +125,24 @@ function withDeclaredMethod(requirements, method) {
125
125
  function isSecureScheme(scheme) {
126
126
  return scheme === "https" || scheme === "wss";
127
127
  }
128
+ /**
129
+ * Un refus de route (méthode, domaine, schéma, protocole) SANS pile : c'est un
130
+ * verdict que le routeur RATTRAPE pour essayer la route suivante — deux routes
131
+ * sur un même chemin (`GET` et `POST /x`) le lèvent à chaque requête qui
132
+ * croise la voisine. La pile, toujours la même, coûterait ~3,6 µs par refus
133
+ * (`micro-error-path.mjs`) pour ne rien dire de la requête.
134
+ *
135
+ * @param message - le motif lisible.
136
+ * @param code - statut HTTP (ou code de fermeture WebSocket).
137
+ * @param type - la famille du refus (`method`, `domain`, `scheme`, `protocol`).
138
+ * @param allow - les méthodes servies, pour l'en-tête `Allow` d'un 405.
139
+ */
140
+ function routeRefusal(message, code, type, allow) {
141
+ const error = HttpError.withoutStack(message, code);
142
+ error.type = type;
143
+ if (allow !== void 0) error.allow = allow;
144
+ return error;
145
+ }
128
146
  var Route = class Route {
129
147
  name;
130
148
  path;
@@ -172,6 +190,13 @@ var Route = class Route {
172
190
  /** Route de repli, résolue après toutes les autres. Cf {@link RouteOptions.fallback}. */
173
191
  fallback = false;
174
192
  /**
193
+ * Délai de réponse PROPRE à la route (ms), `null` = celui du serveur.
194
+ * Cf {@link RouteOptions.responseTimeout}.
195
+ */
196
+ responseTimeout = null;
197
+ /** Bornes d'envoi propres à la route. Cf {@link RouteOptions.upload}. */
198
+ upload = null;
199
+ /**
175
200
  * P2.9 — Cache mémoïsé : l'action attend-elle le **flux brut** du body
176
201
  * (`@Body({ stream:true })`) ? `undefined` = pas encore calculé (résolu au 1er
177
202
  * `routeExpectsBodyStream(route)` via lecture `Reflect` des `ParamMeta`, O(1)
@@ -179,6 +204,13 @@ var Route = class Route {
179
204
  */
180
205
  bodyStream;
181
206
  /**
207
+ * Route servie par un point d'entrée gRPC — posé au démarrage par
208
+ * `markGrpcRoute` (`@nodefony/http/grpc`), jamais à la main.
209
+ *
210
+ * @experimental Surface gRPC, ADR-0026 D11.
211
+ */
212
+ grpc;
213
+ /**
182
214
  * P5 — Metadata d'action figées (`@HttpCode`/`@Header`/`@Redirect`/params/
183
215
  * session), memo au 1er hit comme {@link bodyStream} : `undefined` = pas
184
216
  * encore résolu (via `resolveActionMeta`, 1 lecture Reflect par route, O(1)
@@ -208,6 +240,8 @@ var Route = class Route {
208
240
  this.bypassFirewall = obj.bypassFirewall ?? false;
209
241
  this.areaRoleExempt = obj.areaRoleExempt ?? false;
210
242
  this.fallback = obj.fallback ?? false;
243
+ this.responseTimeout = obj.responseTimeout ?? null;
244
+ this.upload = obj.upload ?? null;
211
245
  this.compile();
212
246
  }
213
247
  this.generateId();
@@ -409,7 +443,9 @@ var Route = class Route {
409
443
  variables: this.variables,
410
444
  bypassFirewall: this.bypassFirewall,
411
445
  areaRoleExempt: this.areaRoleExempt,
412
- fallback: this.fallback
446
+ fallback: this.fallback,
447
+ responseTimeout: this.responseTimeout,
448
+ upload: this.upload
413
449
  };
414
450
  }
415
451
  setDefaults(arg) {
@@ -449,10 +485,7 @@ var Route = class Route {
449
485
  matchHostname(context) {
450
486
  if (this.hostRegexp) {
451
487
  if (isDomainAllowed(this.hostRegexp, context.domain)) return true;
452
- const error = new HttpError(`Domain ${context.domain} Forbidden for this route`);
453
- error.code = 403;
454
- error.type = "domain";
455
- throw error;
488
+ throw routeRefusal(`Domain ${context.domain} Forbidden for this route`, 403, "domain");
456
489
  }
457
490
  return true;
458
491
  }
@@ -489,42 +522,19 @@ var Route = class Route {
489
522
  return Object.keys(this.requirements).length;
490
523
  }
491
524
  matchRequirements(context, methodOverride) {
492
- if (this.requirements.methods === void 0 && (methodOverride !== void 0 || context.method === "WEBSOCKET")) {
493
- const error = new HttpError(`Method ${methodOverride ?? "WEBSOCKET"} Not Allowed`);
494
- error.code = 405;
495
- error.type = "method";
496
- error.allow = "";
497
- throw error;
498
- }
525
+ if (this.requirements.methods === void 0 && (methodOverride !== void 0 || context.method === "WEBSOCKET")) throw routeRefusal(`Method ${methodOverride ?? "WEBSOCKET"} Not Allowed`, 405, "method", "");
499
526
  if (this.hasRequirements()) for (const i in this.requirements) switch (i) {
500
527
  case "methods":
501
528
  if (!this.methodsSet) throw new Error(`Bad config route method : ${String(this.requirements.methods)}`);
502
529
  if (methodOverride !== void 0) {
503
- if (!this.methodsSet.has("WEBSOCKET") || !this.methodsSet.has(methodOverride)) {
504
- const error = new HttpError(`Method ${methodOverride} Not Allowed`);
505
- error.code = 405;
506
- error.type = "method";
507
- error.allow = this.methodsAllow ?? "";
508
- throw error;
509
- }
530
+ if (!bridgeServesVerb(this.methodsSet, methodOverride)) throw routeRefusal(`Method ${methodOverride} Not Allowed`, 405, "method", this.methodsAllow ?? "");
510
531
  break;
511
532
  }
512
- if (!this.methodsSet.has(context.method)) {
513
- const error = new HttpError(`Method ${context.method} Not Allowed`);
514
- error.code = 405;
515
- error.type = "method";
516
- error.allow = this.methodsAllow ?? "";
517
- throw error;
518
- }
533
+ if (!this.methodsSet.has(context.method)) throw routeRefusal(`Method ${context.method} Not Allowed`, 405, "method", this.methodsAllow ?? "");
519
534
  break;
520
535
  case "scheme": {
521
536
  const required = this.requirements.scheme;
522
- if (required !== void 0 && isSecureScheme(required) !== isSecureScheme(context.scheme)) {
523
- const error = new HttpError(`Scheme ${context.scheme} Forbidden for this route : ${required} required`);
524
- error.code = 403;
525
- error.type = "scheme";
526
- throw error;
527
- }
537
+ if (required !== void 0 && isSecureScheme(required) !== isSecureScheme(context.scheme)) throw routeRefusal(`Scheme ${context.scheme} Forbidden for this route : ${required} required`, 403, "scheme");
528
538
  break;
529
539
  }
530
540
  case "domain": break;
@@ -532,18 +542,8 @@ var Route = class Route {
532
542
  const requirement = this.requirements[i];
533
543
  if (!requirement) return true;
534
544
  if (typeof requirement === "string") {
535
- if (context.acceptedProtocol !== requirement) {
536
- const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
537
- error.code = 1002;
538
- error.type = "protocol";
539
- throw error;
540
- }
541
- } else {
542
- const error = new HttpError(`Protocol ${context.acceptedProtocol} Not Supported`);
543
- error.code = 1002;
544
- error.type = "protocol";
545
- throw error;
546
- }
545
+ if (context.acceptedProtocol !== requirement) throw routeRefusal(`Protocol ${context.acceptedProtocol} Not Supported`, 1002, "protocol");
546
+ } else throw routeRefusal(`Protocol ${context.acceptedProtocol} Not Supported`, 1002, "protocol");
547
547
  }
548
548
  }
549
549
  return true;
@@ -211,7 +211,7 @@ const LOGIN_PAGE_TEMPLATE = `<!doctype html>
211
211
  </p>
212
212
  <ul class="badges">
213
213
  <li class="agentic">Agentic ready</li>
214
- <li>Node.js ≥ 24.14</li>
214
+ <li>Node.js 24.16+ ou 26+</li>
215
215
  <li>TypeScript strict</li>
216
216
  <li>ESM only</li>
217
217
  <li>Licence Apache 2.0</li>
@@ -28,6 +28,7 @@ import Eta from "./nodefony/service/Eta.js";
28
28
  import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, getRouteSchema } from "./nodefony/decorators/routerDecorators.js";
29
29
  export type { IHttpMethodDecorator } from "./nodefony/decorators/routerDecorators.js";
30
30
  export { validateRouteInput } from "./nodefony/src/routeSchema.js";
31
+ export { contentDisposition } from "./nodefony/src/Controller.js";
31
32
  export type { IRouteSchema, RouteInput, RoutePart, RawRouteInput, RawRouteParams, RawRouteQuery, RawRouteHeaders, } from "./nodefony/src/routeSchema.js";
32
33
  declare module "nodefony" {
33
34
  interface NodefonyModuleConfig {
@@ -113,10 +113,30 @@ export interface IServiceScan {
113
113
  * interface à implémenter formellement — le même couplage que `tokenService` ou
114
114
  * `adminBroker`.
115
115
  *
116
+ * Deux sources peuvent déclarer la MÊME ressource — une zone du pare-feu et la
117
+ * porte MCP qu'elle couvre, typiquement — : elles sont fusionnées
118
+ * ({@link mergeProtectedResources}), jamais départagées par leur ordre.
119
+ *
116
120
  * @param container - conteneur de services de l'application
117
- * @returns les ressources déclarées, dans l'ordre des services
121
+ * @returns les ressources déclarées, une entrée par ressource, dans l'ordre des
122
+ * services
118
123
  */
119
124
  export declare function collectProtectedResources(container: IServiceScan): readonly IProtectedResourceInput[];
125
+ /**
126
+ * Une ressource, UN document : fusionne les déclarations d'une même ressource
127
+ * (même URI canonique) — l'union de leurs serveurs d'autorisation et de leurs
128
+ * scopes ; le premier nom et la première documentation déclarés.
129
+ *
130
+ * Pourquoi fusionner plutôt que garder la première : une zone du pare-feu
131
+ * publie sa ressource avec les émetteurs de confiance, et la porte MCP qu'elle
132
+ * couvre publie la MÊME ressource avec les scopes de ses outils. Garder la
133
+ * première jetait les scopes sans un mot — et un agent qui suit le document
134
+ * demandait alors un jeton sans eux, qui n'ouvrait pas l'outil.
135
+ *
136
+ * @param entries - déclarations, dans l'ordre des services
137
+ * @returns une entrée par ressource, à la place de la première déclaration
138
+ */
139
+ export declare function mergeProtectedResources(entries: readonly IProtectedResourceInput[]): IProtectedResourceInput[];
120
140
  /**
121
141
  * Chemins bien connus à monter pour un jeu de ressources déclarées.
122
142
  *
@@ -9,6 +9,7 @@ import { ControllerConstructor } from "../src/Route.js";
9
9
  import type { IRouteSchema, RawRouteInput, RouteInput } from "../src/routeSchema.js";
10
10
  import type { SessionIntent } from "@nodefony/http";
11
11
  type Constructor<T = {}> = new (...args: any[]) => T;
12
+ declare const getReservedActionNames: () => Map<string, string>;
12
13
  /**
13
14
  * Rattache un ou plusieurs contrôleurs à un module, dont ils suivent le cycle de vie.
14
15
  *
@@ -794,7 +795,7 @@ declare function resolveActionMeta(routeDef: {
794
795
  classMethod?: string | undefined;
795
796
  actionMeta?: RouteActionMeta | undefined;
796
797
  }): RouteActionMeta;
797
- export { route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, resolveSessionIntent, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, resolveParamArg, buildParamArgs, routeExpectsBodyStream, computeActionMeta, resolveActionMeta, extractActionScopes, };
798
+ export { route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, resolveSessionIntent, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, resolveParamArg, buildParamArgs, routeExpectsBodyStream, computeActionMeta, resolveActionMeta, extractActionScopes, applyDeclaredRouteOptions, getReservedActionNames, };
798
799
  /** Options de {@link RequireAuthentication}. */
799
800
  export interface IRequireAuthenticationOptions {
800
801
  /** Âge maximal de la connexion, en secondes. Omis = aucune limite d'âge. */
@@ -0,0 +1,75 @@
1
+ import Controller from "../src/Controller.js";
2
+ /**
3
+ * Classe de base d'un point d'entrée gRPC (ADR-0026 D1), jumelle de
4
+ * `RealtimeController` : marquée par `@GrpcService(Contrat)`, déclarée par
5
+ * `@controllers([...])`. Chaque méthode du contrat est une méthode de la
6
+ * classe, appelée par l'adaptateur Connect avec `(request, HandlerContext)` ;
7
+ * la logique métier vit dans un service injecté.
8
+ *
9
+ * Singleton par défaut : aucune instance par appel, l'adaptateur lie les
10
+ * méthodes une fois. `@Scope("request")` donne une instance par appel.
11
+ *
12
+ * Ce que le framework décide pour chaque méthode, sans code de ta part :
13
+ * - les gardes (`@IsGranted`, `@RequireAuthentication`…) posées sur la
14
+ * méthode ou la classe s'appliquent ; un refus part en 16
15
+ * `UNAUTHENTICATED` sans identité, en 7 `PERMISSION_DENIED` sinon ;
16
+ * - `this.user`, l'ALS et la portée `request` valent pendant tout l'appel,
17
+ * flux compris ;
18
+ * - une `ConnectError` part telle quelle ; une `HttpError` est traduite par
19
+ * la table de `google/rpc/code.proto` ; toute autre exception devient 13
20
+ * `INTERNAL`. Dans un flux, la traduction ne vaut qu'avant le premier
21
+ * message ;
22
+ * - un flux est interrompu en 14 `UNAVAILABLE` à l'arrêt du serveur et en 16
23
+ * à la révocation de son identité : passer `ctx.signal` à ses sources ;
24
+ * - messages bornés à 4 Mio (section `grpc` de la configuration http).
25
+ *
26
+ * Un décorateur sans effet en gRPC (`@Body`, `@UseSession`, `@Csp`,
27
+ * `@Idempotent`…) sur une méthode du contrat interrompt le démarrage.
28
+ * Guide complet : `docs/grpc.md` du paquet.
29
+ *
30
+ * @example
31
+ * \@GrpcService(GreetService)
32
+ * export class GreetController extends GrpcController {
33
+ * constructor(context: ContextType) {
34
+ * super("GreetController", context);
35
+ * }
36
+ * greet(request: GreetRequest) {
37
+ * return { greeting: `Bonjour, ${request.name} !` };
38
+ * }
39
+ * }
40
+ *
41
+ * @experimental Surface gRPC, ADR-0026 D11.
42
+ */
43
+ export declare abstract class GrpcController extends Controller {
44
+ /**
45
+ * Le point d'entrée de transport, posé au démarrage sur le prototype de la
46
+ * classe déclarée (`assertScope`) : une lecture de prototype par appel.
47
+ */
48
+ private readonly grpcEndpoint;
49
+ /**
50
+ * Vérifie la déclaration gRPC de la classe et fige ses routes — au
51
+ * démarrage (`Router.setController`), jamais à la première requête.
52
+ *
53
+ * Refuse : une classe sans `@GrpcService` ; une méthode du contrat absente
54
+ * de la classe ou dont le nom est réservé par le framework ; un décorateur
55
+ * sans effet en gRPC sur une méthode du contrat (ou sur la classe) — le
56
+ * refus nomme la méthode et le décorateur.
57
+ *
58
+ * Fige ensuite, sur chaque route, la méta d'action lue sur la MÉTHODE DU
59
+ * CONTRAT (l'action exécutée reste celle de la base) et le corps laissé en
60
+ * flux : les gardes valent quel que soit l'ordre des décorateurs de classe.
61
+ *
62
+ * @throws BootConfigurationError sur une déclaration refusée.
63
+ */
64
+ static assertScope(): void;
65
+ /**
66
+ * L'action de base de toute route gRPC : remet la requête et la réponse
67
+ * brutes à l'adaptateur, qui appelle la méthode du contrat et écrit la
68
+ * réponse. Synchrone, sauf pour un flux dont la session est à sauver : la
69
+ * promesse de la remise est alors rendue au résolveur, qui en lit l'échec.
70
+ *
71
+ * @throws Error quand la classe n'a pas été enregistrée par le routeur, ou
72
+ * hors d'une requête HTTP.
73
+ */
74
+ serveGrpc(): void | Promise<void>;
75
+ }
@@ -0,0 +1,51 @@
1
+ import type { DescService } from "@bufbuild/protobuf";
2
+ import type { ServiceImpl } from "@connectrpc/connect";
3
+ import type { GrpcController } from "./GrpcController.js";
4
+ /**
5
+ * Une classe qui peut servir le contrat `S` : elle étend `GrpcController` et
6
+ * implémente CHAQUE méthode du contrat avec sa signature générée. Une méthode
7
+ * manquante ou mal typée ne compile pas.
8
+ *
9
+ * @experimental Surface gRPC, ADR-0026 D11.
10
+ */
11
+ export type GrpcServiceClass<S extends DescService> = new (...args: never[]) => GrpcController & ServiceImpl<S>;
12
+ /**
13
+ * Déclare un contrôleur comme le point d'entrée du service gRPC `service`
14
+ * (le descripteur généré par `buf`, ex. `EchoService` de `echo_pb.ts`).
15
+ *
16
+ * Monte une route littérale du Router par méthode du contrat,
17
+ * `POST /<paquet.Service>/<Méthode>`, plus `GET` pour une méthode déclarée
18
+ * `option idempotency_level = NO_SIDE_EFFECTS` (le GET de Connect). Ces
19
+ * routes passent par tout le pipeline — pare-feu, `@IsGranted`, hôtes
20
+ * virtuels, profileur, liste des routes —, et leurs gardes se lisent sur la
21
+ * méthode du contrat. La classe se déclare ensuite par `@controllers([...])`,
22
+ * comme tout point d'entrée d'un module.
23
+ *
24
+ * Les décorateurs de classe `@Domain` et `@BypassFirewall` se placent SOUS
25
+ * `@GrpcService` (lus au montage des routes, comme sous `@controller`).
26
+ *
27
+ * Le descripteur vient du code généré, jamais écrit à la main : contrat dans
28
+ * `proto/<paquet>/v1/*.proto`, `npx buf generate` vers `nodefony/gen/`, code
29
+ * commité. Les routes répondent sur 5152 (gRPC, gRPC-Web, Connect), 5151
30
+ * (gRPC-Web, Connect : HTTP/1.1, jamais le gRPC natif) et 5153 si h2c est
31
+ * activé. Guide complet : `docs/grpc.md` du paquet.
32
+ *
33
+ * @param service - le descripteur du service.
34
+ * @returns le décorateur de classe.
35
+ * @throws BootConfigurationError quand une autre classe sert déjà ce contrat.
36
+ *
37
+ * @example
38
+ * \@GrpcService(EchoService)
39
+ * export class EchoController extends GrpcController {
40
+ * constructor(context: ContextType) {
41
+ * super("EchoController", context);
42
+ * }
43
+ * \@IsGranted("ROLE_ECHO")
44
+ * say(request: SayRequest) {
45
+ * return { sentence: request.sentence };
46
+ * }
47
+ * }
48
+ *
49
+ * @experimental Surface gRPC, ADR-0026 D11.
50
+ */
51
+ export declare function GrpcService<S extends DescService>(service: S): <C extends GrpcServiceClass<S>>(ctor: C) => C;
@@ -0,0 +1,12 @@
1
+ import type { DescMethod, DescService } from "@bufbuild/protobuf";
2
+ import type Route from "../src/Route.js";
3
+ /** Une route montée par `@GrpcService`, et la méthode du contrat qu'elle sert. */
4
+ export interface IGrpcRoute {
5
+ readonly route: Route;
6
+ readonly method: DescMethod;
7
+ }
8
+ /** Ce que `@GrpcService` a déclaré pour une classe, lu au démarrage. */
9
+ export interface IGrpcDeclaration {
10
+ readonly service: DescService;
11
+ readonly routes: readonly IGrpcRoute[];
12
+ }