@nodefony/framework 10.0.0-alpha.8 → 10.0.0-alpha.9
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.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +5 -4
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +2 -2
- package/dist/nodefony/service/router.js +2 -2
- package/dist/nodefony/src/KernelAdminApi.js +12 -7
- package/docs/admin.md +25 -25
- package/docs/controller.md +44 -44
- package/docs/decorateurs.md +79 -79
- package/docs/idempotence.md +70 -65
- package/docs/routing.md +40 -40
- package/docs/templates.md +7 -7
- package/package.json +9 -9
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,8 @@ import Route from "./nodefony/src/Route.js";
|
|
|
7
7
|
import Controller from "./nodefony/src/Controller.js";
|
|
8
8
|
import { All, Anonymous, Body, BypassFirewall, Cookie, Csp, CsrfExempt, CsrfProtect, CurrentUser, Delete, Domain, Get, Head, Header, Headers, HttpCode, Idempotent, IsGranted, Options, Param, Patch, Post, Put, Query, Redirect, Req, RequireScope, Res, Scope, Session, UploadedFile, UploadedFiles, UseSession, controller, controllers, route, routeExpectsBodyStream } from "./nodefony/decorators/routerDecorators.js";
|
|
9
9
|
import Resolver from "./nodefony/src/Resolver.js";
|
|
10
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
11
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
10
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
11
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
12
12
|
import router_default from "./nodefony/service/router.js";
|
|
13
13
|
import ResourceController from "./nodefony/src/ResourceController.js";
|
|
14
14
|
import AdminApiController from "./nodefony/controller/AdminApiController.js";
|
|
@@ -29,7 +29,7 @@ import { createFrameworkAdminApi } from "./nodefony/src/FrameworkAdminApi.js";
|
|
|
29
29
|
import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
|
|
30
30
|
import Eta from "./nodefony/service/Eta.js";
|
|
31
31
|
import path from "node:path";
|
|
32
|
-
import { AUTO_STORE, EMPTY_INFRA, Kernel, Module, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices, services } from "nodefony";
|
|
32
|
+
import { AUTO_STORE, EMPTY_INFRA, Kernel, Module, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices, services } from "nodefony";
|
|
33
33
|
import { mergeResolvers, mergeTypeDefs } from "@graphql-tools/merge";
|
|
34
34
|
import { makeExecutableSchema, mergeSchemas } from "@graphql-tools/schema";
|
|
35
35
|
//#region index.ts
|
|
@@ -127,7 +127,8 @@ let Framework = class Framework extends Module {
|
|
|
127
127
|
available: listIdempotencyBackends(),
|
|
128
128
|
reason,
|
|
129
129
|
configPath: "framework.idempotency.store",
|
|
130
|
-
location: readStoreLocation(store)
|
|
130
|
+
location: readStoreLocation(store),
|
|
131
|
+
connector: readStoreConnector(store)
|
|
131
132
|
});
|
|
132
133
|
const idem = this.options.idempotency;
|
|
133
134
|
this.#idempotencyGc = scheduleIdempotencyGc(store, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import router_default from "./router.js";
|
|
4
4
|
import AdminApiController from "../controller/AdminApiController.js";
|
|
5
5
|
import { ADMIN_DEFAULT_ROLE, Module, Service, injectable, resolveAdminRole } from "nodefony";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
3
3
|
import { Module, Service, assertPageQuery, injectable } from "nodefony";
|
|
4
4
|
//#region nodefony/service/IdempotencyStore.ts
|
|
5
5
|
const serviceName = "idempotencyStore";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import Route from "../src/Route.js";
|
|
2
2
|
import { routeExpectsBodyStream } from "../decorators/routerDecorators.js";
|
|
3
3
|
import Resolver from "../src/Resolver.js";
|
|
4
|
-
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.
|
|
5
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
4
|
+
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
5
|
+
import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
6
6
|
import { Module, Service, injectable } from "nodefony";
|
|
7
7
|
import { HttpError, isDomainAllowed } from "@nodefony/http";
|
|
8
8
|
//#region nodefony/service/router.ts
|
|
@@ -2,7 +2,7 @@ import { CORE_PACKAGE, checkOutdated, countModuleDocs, listModuleDocs, listModul
|
|
|
2
2
|
import { getResolvedPath, navigateSchemaNode, nodeFlags, notEditableReason, recipeFor, validateLeafValue } from "./configMutation.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { GitService, Syslog, aggregateProvenance, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, readResolvedPath } from "nodefony";
|
|
5
|
+
import { GitService, Syslog, aggregateProvenance, applyResolvedPath, collectDevStatus, computeConfigProvenance, defaultAppConfig, extractJsonSchemaDefaults, extractMarkdownSection, flattenConfigSchema, getActiveLogDriver, listLogDrivers, outlineMarkdown, parseNfEnvOverrides, parseStoreManifest, readResolvedPath } from "nodefony";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
//#region nodefony/src/KernelAdminApi.ts
|
|
@@ -50,11 +50,11 @@ function readAdapterManifest(pkg) {
|
|
|
50
50
|
const pkgJson = join(REPO_ROOT, "node_modules", ...pkg.split("/"), "package.json");
|
|
51
51
|
try {
|
|
52
52
|
if (!existsSync(pkgJson)) return null;
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
53
|
+
const manifest = parseStoreManifest(JSON.parse(readFileSync(pkgJson, "utf8")));
|
|
54
|
+
if (!manifest?.provides) return null;
|
|
55
55
|
return {
|
|
56
|
-
storeKind:
|
|
57
|
-
stores:
|
|
56
|
+
storeKind: manifest.storeKind,
|
|
57
|
+
stores: manifest.stores
|
|
58
58
|
};
|
|
59
59
|
} catch {
|
|
60
60
|
return null;
|
|
@@ -692,7 +692,12 @@ function createKernelAdminApi(kernel) {
|
|
|
692
692
|
if ((res.resolved === "drizzle" || res.resolved === "mongoose") && infra.database) return "NF_DATABASE_URL";
|
|
693
693
|
return null;
|
|
694
694
|
};
|
|
695
|
-
const
|
|
695
|
+
const loadedPackages = /* @__PURE__ */ new Set();
|
|
696
|
+
for (const mod of Object.values(kernel.modules)) {
|
|
697
|
+
const npmName = mod.package?.name;
|
|
698
|
+
if (typeof npmName === "string") loadedPackages.add(npmName);
|
|
699
|
+
loadedPackages.add(mod.name);
|
|
700
|
+
}
|
|
696
701
|
return {
|
|
697
702
|
engines: OFFICIAL_STORE_ADAPTERS.map((a) => {
|
|
698
703
|
const manifest = readAdapterManifest(a.package);
|
|
@@ -703,7 +708,7 @@ function createKernelAdminApi(kernel) {
|
|
|
703
708
|
kind: manifest?.storeKind ?? "durable",
|
|
704
709
|
provides: manifest?.stores ?? [],
|
|
705
710
|
installed: manifest !== null || isPackageInstalled(a.package),
|
|
706
|
-
loaded:
|
|
711
|
+
loaded: loadedPackages.has(a.package) || loadedPackages.has(a.engine)
|
|
707
712
|
};
|
|
708
713
|
}),
|
|
709
714
|
infra: {
|
package/docs/admin.md
CHANGED
|
@@ -264,8 +264,8 @@ sequenceDiagram
|
|
|
264
264
|
|
|
265
265
|
| # | Étape | Où |
|
|
266
266
|
| --- | ----------------------------------------- | ---------------------------------------------------------- |
|
|
267
|
-
| 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts:
|
|
268
|
-
| 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:
|
|
267
|
+
| 1 | Le producteur s'enregistre | `AdminBroker.register()` (`AdminBroker.ts:53`) |
|
|
268
|
+
| 2 | Le framework monte tout | `AdminBroker.mountAll()` (`AdminBroker.ts:112`) |
|
|
269
269
|
| 3 | Une route par endpoint (nom déterministe) | `Router.createRoute()` (`AdminBroker.ts:124`) |
|
|
270
270
|
| 4 | Le controller pont estampillé une fois | `Router.setController()` idempotent (`AdminBroker.ts:146`) |
|
|
271
271
|
| 5 | Dispatch : lookup de la route | `AdminBroker.resolve()` (`AdminApiController.ts:94`) |
|
|
@@ -275,15 +275,15 @@ sequenceDiagram
|
|
|
275
275
|
Points de conception saillants :
|
|
276
276
|
|
|
277
277
|
- **Le montage FIGE les routes.** Après `mountAll()`, tout `register()` lève
|
|
278
|
-
(`AdminBroker.ts:
|
|
278
|
+
(`AdminBroker.ts:53`) : on ne monte pas une route à chaud (Zero surprise en prod). Le broker garde
|
|
279
279
|
la trace via son drapeau `mounted`.
|
|
280
|
-
- **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts:
|
|
280
|
+
- **Le nom de route est déterministe** : `admin.<ns>.<method>.<path>` (`AdminBroker.ts:123`) — c'est
|
|
281
281
|
la clé du lookup O(1) que le pont refait à chaque requête.
|
|
282
282
|
- **`Router.setController` n'est appelé qu'une fois** par process (`AdminBroker.ts:146`) : il pose
|
|
283
283
|
une propriété non réinscriptible sur le prototype ; une garde `hasOwnProperty` rend l'appel
|
|
284
284
|
idempotent (multi-broker en test, re-boot).
|
|
285
285
|
- **Le catalogue se construit à la volée** depuis `AdminBroker.list()` + `AdminBroker.routes()`
|
|
286
|
-
(`AdminBroker.ts:
|
|
286
|
+
(`AdminBroker.ts:108`) — jamais un état retenu.
|
|
287
287
|
|
|
288
288
|
> [!IMPORTANT]
|
|
289
289
|
> Convention de route **figée** : le data plane est toujours en **≥ 3 segments**
|
|
@@ -310,8 +310,8 @@ La décision est **fail-closed** : un authentifié **sans** le rôle requis —
|
|
|
310
310
|
laissez-passer.
|
|
311
311
|
|
|
312
312
|
- **Rôle par défaut** : sans `role` explicite, un endpoint exige `ROLE_NODEFONY_ADMIN`
|
|
313
|
-
(`AdminBroker.ts:112` ; défaut du champ `IAdminEndpoint.role`, `IAdminApi.ts:
|
|
314
|
-
- **Endpoint public** : `public: true` (`IAdminApi.ts:
|
|
313
|
+
(`AdminBroker.ts:112` ; défaut du champ `IAdminEndpoint.role`, `IAdminApi.ts:166`).
|
|
314
|
+
- **Endpoint public** : `public: true` (`IAdminApi.ts:175`) → le RBAC du broker est court-circuité
|
|
315
315
|
(`role === ""`, `adminRbac.ts:26`). À réserver aux sondes cloud-native (liveness/readiness) et à
|
|
316
316
|
placer hors d'une zone fermée — sinon le firewall verrouille en amont. Exemple réel :
|
|
317
317
|
`GET /nodefony/kernel/api/livez` (`KernelAdminApi.ts:615`), sorti de `nodefony-admin` par la zone
|
|
@@ -334,7 +334,7 @@ montée avec `[method, "WEBSOCKET"]`. Elle devient donc invocable par le pont WS
|
|
|
334
334
|
expose son statut.
|
|
335
335
|
|
|
336
336
|
Les **mutations** sont pontables par socket. La sécurité d'écriture repose alors sur l'**idempotence**
|
|
337
|
-
(`idempotencyGate()`, `AdminApiController.ts:
|
|
337
|
+
(`idempotencyGate()`, `AdminApiController.ts:131`) : la clé `Idempotency-Key` est **obligatoire en
|
|
338
338
|
WS** (une socket reconnecte et rejoue), **optionnelle en HTTP** (`required: false`,
|
|
339
339
|
`AdminApiController.ts:184`). Un `GET` n'est jamais idempotenté (`AdminApiController.ts:149`) ; la
|
|
340
340
|
porte est évaluée **après** le RBAC (un 403 ne consomme aucune entrée). Le helper est le **même** que
|
|
@@ -364,18 +364,18 @@ sont enregistrés au `onKernelReady` du framework :
|
|
|
364
364
|
|
|
365
365
|
| Namespace | Producteur | Rôle |
|
|
366
366
|
| ----------- | ----------------------------------------------------- | ------------------------------------------------- |
|
|
367
|
-
| `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:
|
|
368
|
-
| `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:
|
|
367
|
+
| `kernel` | `createKernelAdminApi` (`KernelAdminApi.ts:582`) | modules, process, uptime, `livez` |
|
|
368
|
+
| `framework` | `createFrameworkAdminApi` (`FrameworkAdminApi.ts:86`) | dump du Router + **catalogue** + Playground (dev) |
|
|
369
369
|
| `syslog` | `createSyslogAdminApi` (`SyslogAdminApi.ts:95`) | viewer de logs (dev) |
|
|
370
370
|
|
|
371
371
|
Les modules externes s'enregistrent depuis leur propre `onKernelBoot` :
|
|
372
372
|
|
|
373
|
-
| Namespace | Module | Enregistrement
|
|
374
|
-
| ---------- | -------------------- |
|
|
375
|
-
| `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts:121`)
|
|
376
|
-
| `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:
|
|
377
|
-
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:
|
|
378
|
-
| `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:
|
|
373
|
+
| Namespace | Module | Enregistrement |
|
|
374
|
+
| ---------- | -------------------- | ---------------------------------------------------- |
|
|
375
|
+
| `http` | `@nodefony/http` | `createHttpAdminApi` (`http/index.ts:121`) |
|
|
376
|
+
| `security` | `@nodefony/security` | `registerSecurityAdminApi` (`security/index.ts:104`) |
|
|
377
|
+
| `user` | `@nodefony/user` | `adminNamespace` (`UserAdminApi.ts:945`) |
|
|
378
|
+
| `orm` | `@nodefony/orm-core` | `adminNamespace` (`OrmAdminApi.ts:709`) |
|
|
379
379
|
|
|
380
380
|
Pour le détail de chacun, se reporter à la doc de son module — le broker reste agnostique de leur
|
|
381
381
|
contenu.
|
|
@@ -389,14 +389,14 @@ recopiées ici (elles s'y périmeraient).
|
|
|
389
389
|
|
|
390
390
|
| Membre (`IAdminBroker`) | Rôle | Ancre |
|
|
391
391
|
| ------------------------ | -------------------------------------------------------- | -------------------- |
|
|
392
|
-
| `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts:
|
|
393
|
-
| `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts:
|
|
394
|
-
| `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts:
|
|
395
|
-
| `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts:
|
|
396
|
-
| `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts:
|
|
397
|
-
| `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts:
|
|
398
|
-
| `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts:
|
|
399
|
-
| `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts:
|
|
392
|
+
| `register(api)` | Enregistre un producteur (throw si namespace pris/monté) | `AdminBroker.ts:53` |
|
|
393
|
+
| `unregister(ns)` | Retire un producteur (et ses routes si montées) | `AdminBroker.ts:69` |
|
|
394
|
+
| `has(ns)` / `getApi(ns)` | Interrogation du registre | `AdminBroker.ts:91` |
|
|
395
|
+
| `list()` | Producteurs enregistrés (immuable) | `AdminBroker.ts:95` |
|
|
396
|
+
| `resolvePath(ns, path)` | Chemin absolu d'un endpoint sans le monter | `AdminBroker.ts:99` |
|
|
397
|
+
| `mountAll()` | Monte toutes les routes (idempotent) | `AdminBroker.ts:112` |
|
|
398
|
+
| `resolve(routeName)` | Lookup O(1) d'une route montée (utilisé par le pont) | `AdminBroker.ts:104` |
|
|
399
|
+
| `routes()` | Introspection des routes montées (source du catalogue) | `AdminBroker.ts:108` |
|
|
400
400
|
|
|
401
401
|
## 📡 Observabilité — Studio
|
|
402
402
|
|
|
@@ -416,7 +416,7 @@ recopiées ici (elles s'y périmeraient).
|
|
|
416
416
|
|
|
417
417
|
| Symptôme | Cause (dans le code) | Correction |
|
|
418
418
|
| ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
419
|
-
| `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts:
|
|
419
|
+
| `register()` throw « routes figées » | Appel **après** `mountAll()` (`AdminBroker.ts:112`) | Enregistrer au `onKernelBoot`, pas plus tard |
|
|
420
420
|
| `register()` throw « namespace déjà enregistré » | Deux producteurs sur le même `adminNamespace` (`AdminBroker.ts:51`) | Namespace unique ; garder `register()` idempotent (`has(ns)` avant) |
|
|
421
421
|
| 401 sur toute route `/nodefony/<ns>/api/*` | Zone `nodefony-admin` : pas de session BFF (`config.ts:141`) | S'authentifier (login BFF) ; pour une sonde publique → `public: true` + zone anonyme |
|
|
422
422
|
| 403 alors qu'on est connecté | Rôle manquant, `isAdminGranted` fail-closed (`adminRbac.ts:24`) | Doter le compte du rôle requis (défaut `ROLE_NODEFONY_ADMIN`) |
|
package/docs/controller.md
CHANGED
|
@@ -45,11 +45,11 @@ flowchart LR
|
|
|
45
45
|
|
|
46
46
|
Trois idées à retenir :
|
|
47
47
|
|
|
48
|
-
1. **Tu hérites de `Service`** — `Controller` étend `Service` (`Controller.ts:
|
|
48
|
+
1. **Tu hérites de `Service`** — `Controller` étend `Service` (`Controller.ts:146`). Tu récupères
|
|
49
49
|
donc gratuitement le container (`this.get()`), les logs (`this.log()`) et les événements.
|
|
50
50
|
2. **Tu ne construis rien toi-même** — le `Resolver` instancie ta classe via l'injecteur
|
|
51
|
-
(`Resolver.newController()`, `Resolver.ts:
|
|
52
|
-
3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:
|
|
51
|
+
(`Resolver.newController()`, `Resolver.ts:254`), jamais un `new` direct.
|
|
52
|
+
3. **Ton `return` EST la réponse** — `Resolver.returnController()` (`Resolver.ts:827`) traduit la
|
|
53
53
|
valeur retournée : objet → JSON, string → corps brut, `void` → « j'ai répondu moi-même ».
|
|
54
54
|
|
|
55
55
|
## 📖 Lexique
|
|
@@ -196,14 +196,14 @@ Le tableau ci-dessous donne la séquence exacte, avec l'ancre qui la prouve :
|
|
|
196
196
|
| # | Étape | Où |
|
|
197
197
|
| --- | --------------------------------------- | --------------------------------------------------- |
|
|
198
198
|
| 1 | Appariement de la route | `router.resolve()` (`http-kernel.ts:1324`) |
|
|
199
|
-
| 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts:
|
|
199
|
+
| 2 | En-têtes de sécurité applicatifs | `applySecurityHeaders()` (`http-kernel.ts:1381`) |
|
|
200
200
|
| 3 | Parse du corps (sauf `@Body({stream})`) | `http-kernel.ts:1316` |
|
|
201
201
|
| 4 | Armement de la route (sans instance) | `prepareFrontController()` (`http-kernel.ts:767`) |
|
|
202
|
-
| 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts:
|
|
203
|
-
| 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts:
|
|
204
|
-
| 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts:
|
|
202
|
+
| 5 | CSRF | `firewall.enforceCsrf()` (`http-kernel.ts:1479`) |
|
|
203
|
+
| 6 | Session (reprise ou ouverture) | `HttpKernel.startSession()` (`http-kernel.ts:1152`) |
|
|
204
|
+
| 7 | Firewall — **authentification** | `firewall.handleSecurity()` (`http-kernel.ts:1490`) |
|
|
205
205
|
| 8 | Autorisation `@IsGranted` | `Resolver.executeAction()` (`Resolver.ts:334`) |
|
|
206
|
-
| 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts:
|
|
206
|
+
| 9 | **Instanciation DI + `initialize()`** | `Resolver.executeAction()` (`Resolver.ts:335`) |
|
|
207
207
|
| 10 | **Ton action** | `controller[methodKey]()` (`Resolver.ts:382`) |
|
|
208
208
|
|
|
209
209
|
> [!IMPORTANT]
|
|
@@ -257,7 +257,7 @@ Si ton `initialize()` lève, l'exception remonte le pipeline et sort en réponse
|
|
|
257
257
|
Chaque étape est chronométrée sous le nom d'une **phase**, lisible dans la debug bar et le profileur :
|
|
258
258
|
`resolve` · `initialize` (DI + ton hook) · `parse` · `firewall` · `action` · `render` · `send`.
|
|
259
259
|
La phase `initialize` existe précisément pour que le temps passé dans ton hook et dans la résolution
|
|
260
|
-
DI **soit imputé à quelqu'un** au lieu de disparaître dans le bloc `action` (`Resolver.ts:
|
|
260
|
+
DI **soit imputé à quelqu'un** au lieu de disparaître dans le bloc `action` (`Resolver.ts:100`).
|
|
261
261
|
|
|
262
262
|
## 🔌 HTTP et WebSocket — le même contrôleur
|
|
263
263
|
|
|
@@ -300,7 +300,7 @@ class ChatController extends Controller {
|
|
|
300
300
|
| `initialize()` | À chaque requête | **Une seule fois**, au handshake |
|
|
301
301
|
|
|
302
302
|
La réutilisation de l'instance vient du cache posé sur le container du contexte
|
|
303
|
-
(`Resolver.newController()`, `Resolver.ts:
|
|
303
|
+
(`Resolver.newController()`, `Resolver.ts:254`) : le contexte WS étant partagé par la connexion, le
|
|
304
304
|
contrôleur l'est aussi. Un garde-fou vérifie que l'instance cachée est bien de la classe de la route
|
|
305
305
|
courante et la reconstruit sinon (`Resolver.ts:344-347`) — sans quoi un message invoquant une autre
|
|
306
306
|
action se tromperait d'objet.
|
|
@@ -310,9 +310,9 @@ action se tromperait d'objet.
|
|
|
310
310
|
> frame 2 — pratique pour un état de conversation, piège si tu comptais sur une instance neuve. En
|
|
311
311
|
> HTTP, l'inverse : chaque requête repart d'une instance vierge.
|
|
312
312
|
|
|
313
|
-
Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:
|
|
313
|
+
Côté WebSocket, l'ordre est encore plus marqué : `HttpKernel.onConnect()` (`http-kernel.ts:1711`)
|
|
314
314
|
appelle `handleFrontController()` (donc `initialize()`) **avant** `startSession()`
|
|
315
|
-
(`http-kernel.ts:
|
|
315
|
+
(`http-kernel.ts:1152`), avant l'acceptation de la socket, et avant le firewall
|
|
316
316
|
(`http-kernel.ts:1457`).
|
|
317
317
|
|
|
318
318
|
## 🧠 D'où viennent `request`, `response`, `session`
|
|
@@ -322,16 +322,16 @@ sont des accesseurs qui dérivent du contexte **vivant**, selon le motif `champ
|
|
|
322
322
|
|
|
323
323
|
| Raccourci | Ce que tu obtiens | Ancre |
|
|
324
324
|
| ---------------- | -------------------------------------------------- | ------------------- |
|
|
325
|
-
| `this.context` | Le contexte transport de la requête courante | `Controller.ts:
|
|
325
|
+
| `this.context` | Le contexte transport de la requête courante | `Controller.ts:180` |
|
|
326
326
|
| `this.route` | La route matchée | `Controller.ts:158` |
|
|
327
327
|
| `this.request` | La requête (HTTP, HTTP/2 ou WS) | `Controller.ts:162` |
|
|
328
|
-
| `this.response` | La réponse du transport | `Controller.ts:
|
|
328
|
+
| `this.response` | La réponse du transport | `Controller.ts:203` |
|
|
329
329
|
| `this.method` | La méthode HTTP (ou `WEBSOCKET`) | `Controller.ts:212` |
|
|
330
330
|
| `this.queryGet` | Les paramètres de la query string | `Controller.ts:221` |
|
|
331
331
|
| `this.queryPost` | Le corps parsé | `Controller.ts:248` |
|
|
332
332
|
| `this.body` | Le corps parsé — alias de `queryPost` | `Controller.ts:248` |
|
|
333
333
|
| `this.queryFile` | Les fichiers uploadés | `Controller.ts:239` |
|
|
334
|
-
| `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts:
|
|
334
|
+
| `this.session` | La session **ou `null`** si elle n'est pas activée | `Controller.ts:279` |
|
|
335
335
|
|
|
336
336
|
Pourquoi des accesseurs plutôt que des champs recopiés au constructeur : **la fraîcheur et le coût**.
|
|
337
337
|
Une valeur recopiée vieillit dès que le pipeline modifie le contexte, et recopier quatre structures
|
|
@@ -340,7 +340,7 @@ gratuitement.
|
|
|
340
340
|
|
|
341
341
|
### La session est **lazy** — elle n'existe que si tu la demandes
|
|
342
342
|
|
|
343
|
-
`this.session` est un simple getter sur `context.session` (`Controller.ts:
|
|
343
|
+
`this.session` est un simple getter sur `context.session` (`Controller.ts:279`). Il n'y a **pas** de
|
|
344
344
|
`startSession()` à appeler depuis un contrôleur : l'activation se déclare sur la route, avec
|
|
345
345
|
`@UseSession()` (ou un paramètre `@Session()`, qui vaut déclaration implicite), et le pipeline
|
|
346
346
|
l'exécute à son point unique. Sans déclaration et sans cookie de session entrant, **aucune session
|
|
@@ -387,26 +387,26 @@ Ce que ça change, concrètement :
|
|
|
387
387
|
|
|
388
388
|
## 🧰 Répondre — ce que ton `return` déclenche
|
|
389
389
|
|
|
390
|
-
Le traducteur unique est `Resolver.returnController()` (`Resolver.ts:
|
|
390
|
+
Le traducteur unique est `Resolver.returnController()` (`Resolver.ts:827`). Il regarde le **type**
|
|
391
391
|
de ce que tu as retourné :
|
|
392
392
|
|
|
393
393
|
<!-- prettier-ignore -->
|
|
394
394
|
| Tu retournes… | Ce qui se passe | Ancre |
|
|
395
395
|
| --- | --- | --- |
|
|
396
|
-
| Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts:
|
|
396
|
+
| Une `Promise` / un thenable | Déballée puis re-traitée (récursif) | `Resolver.ts:830-840` |
|
|
397
397
|
| Une `string` | Envoyée telle quelle en corps | `Resolver.ts:711` |
|
|
398
|
-
| Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts:
|
|
398
|
+
| Un objet simple ou un tableau | **Auto-JSON** : `application/json` + sérialisation | `Resolver.ts:870` |
|
|
399
399
|
| Un `number` / un `boolean` | Auto-JSON scalaire (RFC 8259 §2 : `42`, `true` sont des documents valides) | `Resolver.ts:734` |
|
|
400
|
-
| Un `Buffer` | Envoyé brut | `Resolver.ts:
|
|
401
|
-
| Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts:
|
|
402
|
-
| `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts:
|
|
403
|
-
| `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:
|
|
404
|
-
| Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:
|
|
400
|
+
| Un `Buffer` | Envoyé brut | `Resolver.ts:854` |
|
|
401
|
+
| Une `Response` (via un `render*`) | Retournée telle quelle — l'envoi a déjà eu lieu | `Resolver.ts:581` |
|
|
402
|
+
| `void`/`null` **et** statut 204/205/304 | Réponse **vide envoyée** (RFC 9110 : ces statuts n'ont pas de corps) | `NO_BODY_STATUS` (`Resolver.ts:948`) |
|
|
403
|
+
| `void`/`null` avec tout autre statut | `waitAsync` : « l'action enverra plus tard » | `Resolver.ts:932` |
|
|
404
|
+
| Une instance de classe (entité ORM, DTO) | **Non sérialisée** → `waitAsync` (le teardown avertit du blocage) | `Resolver.ts:906-913` |
|
|
405
405
|
|
|
406
406
|
> [!WARNING]
|
|
407
407
|
> **Le piège n° 1 : `return null` sur un statut à corps.** Le framework l'interprète comme « je
|
|
408
408
|
> répondrai moi-même » et attend — jusqu'au timeout. La distinction se fait sur le **statut** :
|
|
409
|
-
> `NO_BODY_STATUS` (`Resolver.ts:
|
|
409
|
+
> `NO_BODY_STATUS` (`Resolver.ts:948`) contient 204, 205 et 304. Donc un `@Delete` qui fait
|
|
410
410
|
> `@HttpCode(204)` puis `return null` répond bien 204 vide ; le même `return null` sans `@HttpCode`
|
|
411
411
|
> laisse la requête pendue.
|
|
412
412
|
|
|
@@ -419,12 +419,12 @@ Quand tu veux piloter l'envoi plutôt que retourner une valeur :
|
|
|
419
419
|
|
|
420
420
|
| Helper | Pour… | Ancre |
|
|
421
421
|
| -------------------------------------------- | -------------------------------------------------------- | ------------------- |
|
|
422
|
-
| `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:
|
|
422
|
+
| `renderJson(obj, status?, headers?)` | JSON explicite avec statut/en-têtes | `Controller.ts:481` |
|
|
423
423
|
| `render(data, encoding?, status?, headers?)` | Envoyer un corps quelconque via le contexte | `Controller.ts:377` |
|
|
424
424
|
| `renderView(path, params, status?)` | Rendre un template **Eta** (avec les helpers frontend) | `Controller.ts:410` |
|
|
425
425
|
| `renderResponse(data, encoding?, …)` | Poser statut + en-têtes, puis envoyer | `Controller.ts:392` |
|
|
426
|
-
| `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:
|
|
427
|
-
| `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:
|
|
426
|
+
| `redirect(url, status?, headers?)` | Rediriger | `Controller.ts:500` |
|
|
427
|
+
| `forward("module:controller:action")` | Déléguer à une autre action **sans** aller-retour réseau | `Controller.ts:534` |
|
|
428
428
|
| `setContextJson()` / `setContextHtml()` | Choisir le type de contenu avant d'envoyer | `Controller.ts:319` |
|
|
429
429
|
|
|
430
430
|
`renderView()` mesure sa propre phase `render` et injecte automatiquement les aides frontend
|
|
@@ -437,7 +437,7 @@ Quand tu veux piloter l'envoi plutôt que retourner une valeur :
|
|
|
437
437
|
> [!TIP]
|
|
438
438
|
> **Redirection : le code par défaut est 302** (Found), pas 301. Un statut absent ou hors de la liste
|
|
439
439
|
> RFC 9110 §15.4 (301, 302, 303, 307, 308) retombe sur 302 avec un log d'avertissement
|
|
440
|
-
> (`Response.redirect()`, `Response.ts:
|
|
440
|
+
> (`Response.redirect()`, `Response.ts:611`). Un 301 par défaut piégeait : les navigateurs le mettent
|
|
441
441
|
> en cache de façon quasi irréversible.
|
|
442
442
|
|
|
443
443
|
## 📁 Servir un fichier — téléchargement et flux média
|
|
@@ -446,7 +446,7 @@ Deux besoins distincts, deux helpers.
|
|
|
446
446
|
|
|
447
447
|
### Téléchargement — `renderFileDownload()`
|
|
448
448
|
|
|
449
|
-
`renderFileDownload(file, options?, headers?)` (`Controller.ts:
|
|
449
|
+
`renderFileDownload(file, options?, headers?)` (`Controller.ts:625`) pose
|
|
450
450
|
`Content-Disposition: attachment`, `Content-Length`, le type MIME du fichier, puis délègue au moteur
|
|
451
451
|
de flux. Le fichier est résolu **sans bloquer l'event loop** (`getFileAsync()`, `Controller.ts:586`) ;
|
|
452
452
|
la variante synchrone `getFile()` existe encore mais est marquée obsolète — elle appelle `lstatSync`
|
|
@@ -454,7 +454,7 @@ et gèle le process le temps du stat.
|
|
|
454
454
|
|
|
455
455
|
### Lecture en continu — `renderMediaStream()`
|
|
456
456
|
|
|
457
|
-
`renderMediaStream(file, headers?, options?)` (`Controller.ts:
|
|
457
|
+
`renderMediaStream(file, headers?, options?)` (`Controller.ts:780`) implémente les **requêtes par
|
|
458
458
|
plage** (RFC 9110 §14), ce qui permet à un lecteur vidéo de sauter dans le flux :
|
|
459
459
|
|
|
460
460
|
| Le client envoie… | Réponse |
|
|
@@ -474,7 +474,7 @@ donc testable sans serveur.
|
|
|
474
474
|
**nettoyage** : le flux est ouvert avec `autoClose: false`, et un client qui raccroche en plein
|
|
475
475
|
téléchargement laisserait sinon un descripteur de fichier ouvert et une promesse pendue à jamais. Un
|
|
476
476
|
écouteur `close` sur la réponse détruit le flux, ce qui déclenche la fermeture du descripteur et
|
|
477
|
-
résout la promesse (`Controller.ts:
|
|
477
|
+
résout la promesse (`Controller.ts:729-734`), puis se retire lui-même (`Controller.ts:746`). Un
|
|
478
478
|
téléchargement interrompu ne coûte donc **rien** en ressource retenue.
|
|
479
479
|
|
|
480
480
|
## ⚠️ Erreurs — lever, rendre, observer
|
|
@@ -489,11 +489,11 @@ throw new nodefonyError("Article introuvable", 404); // statut porté par l'erre
|
|
|
489
489
|
throw new HttpError("Not Found", 404, this.context); // variante enrichie du contexte
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts:
|
|
492
|
+
L'exception remonte jusqu'à `HttpKernel.onError()` (`http-kernel.ts:879`), qui délègue la mise en
|
|
493
493
|
forme au rendeur d'erreurs. Ce qui en sort :
|
|
494
494
|
|
|
495
495
|
- **statut normalisé** — un code absent (ou l'ancien quirk `200`) devient **500**
|
|
496
|
-
(`normalizeHttpStatus()`, `error-renderer.ts:
|
|
496
|
+
(`normalizeHttpStatus()`, `error-renderer.ts:642`) ;
|
|
497
497
|
- **corps structuré** : `{ code, message, result: null, error: {…}, nodefony: {…} }`, l'enveloppe
|
|
498
498
|
`nodefony` portant l'environnement, l'URL et l'**identifiant de requête** — de quoi retrouver la
|
|
499
499
|
trace complète dans les logs ;
|
|
@@ -501,13 +501,13 @@ forme au rendeur d'erreurs. Ce qui en sort :
|
|
|
501
501
|
ne tente pas de rendre — il journalise et s'arrête (`http-kernel.ts:770-775`).
|
|
502
502
|
|
|
503
503
|
En **WebSocket**, il n'y a pas de statut : l'erreur devient un **code de fermeture** RFC 6455
|
|
504
|
-
(`renderWebsocket()`, `error-renderer.ts:
|
|
504
|
+
(`renderWebsocket()`, `error-renderer.ts:518`) — 401/403 → 1008 (violation de politique),
|
|
505
505
|
5xx → 1011 (erreur interne), le reste → 4004 (plage privée). Si la socket n'est pas encore acceptée,
|
|
506
506
|
c'est un **rejet** de handshake.
|
|
507
507
|
|
|
508
508
|
> [!NOTE]
|
|
509
509
|
> Les erreurs de ton action remontent **seules** : le Resolver n'enveloppe pas l'appel dans un
|
|
510
|
-
> `try/catch` inutile (`Resolver.ts:
|
|
510
|
+
> `try/catch` inutile (`Resolver.ts:444-445`). Inutile d'attraper pour re-lever — sauf si tu veux
|
|
511
511
|
> vraiment traduire l'erreur en un autre statut.
|
|
512
512
|
|
|
513
513
|
## 🧩 Services injectés — trois façons
|
|
@@ -521,7 +521,7 @@ explicite :
|
|
|
521
521
|
const catalog = this.get<CatalogService>("catalog"); // null si absent ou container nettoyé
|
|
522
522
|
```
|
|
523
523
|
|
|
524
|
-
`Service.get()` (`Service.ts:
|
|
524
|
+
`Service.get()` (`Service.ts:518`) est une **façade sûre** : elle retourne `null` au lieu de lever si
|
|
525
525
|
le container a déjà été détaché. C'est le style à privilégier dans `initialize()`.
|
|
526
526
|
|
|
527
527
|
### 2. Injection par le constructeur — `@inject`
|
|
@@ -568,7 +568,7 @@ code du framework applique — et attend de toi — les règles suivantes :
|
|
|
568
568
|
(`Controller.ts:574`).
|
|
569
569
|
- **Métadonnées d'action figées** : `@HttpCode`, `@Header`, les paramètres décorés et l'intention de
|
|
570
570
|
session sont calculés **une fois** par route puis mémorisés, au lieu d'être relus par `Reflect` à
|
|
571
|
-
chaque requête (`resolveActionMeta()` appelé en `Resolver.ts:
|
|
571
|
+
chaque requête (`resolveActionMeta()` appelé en `Resolver.ts:469`).
|
|
572
572
|
- **Gardes payées seulement si présentes** : sans `@IsGranted`, la vérification d'autorisation est
|
|
573
573
|
un test de nullité (`Resolver.ts:334`) — 0 lookup, 0 `await`, 0 allocation.
|
|
574
574
|
- **Ta part du contrat** : pas de structure allouée « au cas où » dans le constructeur ni dans
|
|
@@ -578,13 +578,13 @@ code du framework applique — et attend de toi — les règles suivantes :
|
|
|
578
578
|
|
|
579
579
|
| Domaine | Norme | Comment le code s'y conforme |
|
|
580
580
|
| -------------------------------- | ------------------------ | -------------------------------------------------------------- |
|
|
581
|
-
| Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:
|
|
581
|
+
| Statuts sans corps (204/205/304) | RFC 9110 §15.3.5/§15.4.5 | `NO_BODY_STATUS` (`Resolver.ts:948`) |
|
|
582
582
|
| Requêtes par plage | RFC 9110 §14.1.2, §14.2 | `parseByteRange()` (`Controller.ts:107`) |
|
|
583
583
|
| Plage insatisfiable → 416 | RFC 9110 §15.5.17 | `renderResponse()` avec 416 (`Controller.ts:392`) |
|
|
584
584
|
| Redirections | RFC 9110 §15.4 | Liste blanche + repli 302 (`Response.ts:534`) |
|
|
585
|
-
| Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts:
|
|
585
|
+
| Média JSON sans `charset` | RFC 8259 §11 | Auto-JSON (`Resolver.ts:892`), vérifié par le banc `auto-json` |
|
|
586
586
|
| Scalaire JSON de premier niveau | RFC 8259 §2 | `number`/`boolean` rendus (`Resolver.ts:734`) |
|
|
587
|
-
| Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts:
|
|
587
|
+
| Codes de fermeture WebSocket | RFC 6455 §7.4 | `renderWebsocket()` (`error-renderer.ts:518`) |
|
|
588
588
|
|
|
589
589
|
## 📡 Observabilité — Studio
|
|
590
590
|
|
|
@@ -601,11 +601,11 @@ code du framework applique — et attend de toi — les règles suivantes :
|
|
|
601
601
|
<!-- prettier-ignore -->
|
|
602
602
|
| Symptôme | Cause (dans le code) | Correction |
|
|
603
603
|
| --- | --- | --- |
|
|
604
|
-
| La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts:
|
|
605
|
-
| Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts:
|
|
604
|
+
| La requête pend puis expire, alors que l'action a bien tourné | `return null`/`undefined` avec un statut à corps → `waitAsync` (`Resolver.ts:906`) | Retourner une valeur, ou poser `@HttpCode(204)` |
|
|
605
|
+
| Réponse vide alors qu'on retourne une entité ORM | Instance de classe **non** sérialisée → `waitAsync` (`Resolver.ts:906`) | Retourner un objet simple, ou `renderJson(entity.toJSON())` |
|
|
606
606
|
| `Route Action not found` | L'action porte un nom déjà utilisé par un membre de `Controller` | Renommer : `session`, `request`, `response`, `context`, `route`, `method`, `query*`, `get`, `set`, `render*`, `redirect`, `forward` sont réservés |
|
|
607
607
|
| `this.session` est `null` dans `initialize()` | La session est activée **après** (`http-kernel.ts:1142`) | Lire la session dans l'action, pas dans le hook |
|
|
608
|
-
| Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts:
|
|
608
|
+
| Effet de bord exécuté pour une requête finalement 401 | `initialize()` tourne avant `firewall.handleSecurity()` (`http-kernel.ts:1490`) | Déplacer l'effet de bord dans l'action |
|
|
609
609
|
| Redirection permanente non voulue | Un statut invalide retombe sur 302, un `301` explicite reste 301 | Passer le code voulu : `this.redirect(url, 302)` |
|
|
610
610
|
| WS : l'état d'une frame « bave » sur la suivante | L'instance est partagée par toute la connexion (`Resolver.ts:262`) | Réinitialiser l'état en tête d'action, ou le porter par message |
|
|
611
611
|
| WS : l'action n'est jamais appelée | Route sans transport `WEBSOCKET` déclaré | `requirements: { methods: ["WEBSOCKET"] }` |
|