@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- package/dist/nodefony/controller/CspReportController.js +2 -2
- package/dist/nodefony/controller/IssuerMetadataController.js +2 -2
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +74 -9
- package/dist/nodefony/controller/SessionAuthController.js +1 -1
- package/dist/nodefony/decorators/routerDecorators.js +101 -13
- package/dist/nodefony/grpc/GrpcController.js +138 -0
- package/dist/nodefony/grpc/GrpcService.js +75 -0
- package/dist/nodefony/grpc/declarations.js +33 -0
- package/dist/nodefony/grpc/index.js +3 -0
- package/dist/nodefony/service/AdminBroker.js +1 -1
- package/dist/nodefony/service/IdempotencyStore.js +1 -1
- package/dist/nodefony/service/router.js +11 -9
- package/dist/nodefony/src/Controller.js +21 -3
- package/dist/nodefony/src/Resolver.js +83 -43
- package/dist/nodefony/src/Route.js +45 -45
- package/dist/nodefony/src/loginPage.js +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +21 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +2 -1
- package/dist/types/nodefony/grpc/GrpcController.d.ts +75 -0
- package/dist/types/nodefony/grpc/GrpcService.d.ts +51 -0
- package/dist/types/nodefony/grpc/declarations.d.ts +12 -0
- package/dist/types/nodefony/grpc/index.d.ts +13 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
- package/dist/types/nodefony/service/router.d.ts +5 -4
- package/dist/types/nodefony/src/Controller.d.ts +15 -3
- package/dist/types/nodefony/src/Resolver.d.ts +34 -7
- package/dist/types/nodefony/src/Route.d.ts +35 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/docs/controller.md +89 -9
- package/docs/grpc.md +690 -0
- package/docs/idempotence.md +11 -6
- package/docs/index.md +12 -8
- package/docs/routing.md +30 -2
- 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.
|
|
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**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* `undefined` (
|
|
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
|
-
|
|
224
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
647
|
-
switch (
|
|
648
|
-
case
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
case
|
|
652
|
-
case
|
|
653
|
-
case
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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>
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|