@nodefony/devkit 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 (32) 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 +1 -1
  4. package/dist/nodefony/controllers/DevkitController.js +1 -1
  5. package/dist/nodefony/controllers/McpController.js +2 -2
  6. package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
  7. package/dist/nodefony/service/DevkitService.js +3 -3
  8. package/package.json +11 -11
  9. package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
  10. package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
  11. package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
  12. package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
  13. package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
  14. package/skills/nodefony-add-realtime-channel/SKILL.md +408 -326
  15. package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
  16. package/skills/nodefony-add-realtime-channel/references/production.md +21 -2
  17. package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
  18. package/skills/nodefony-authentication/SKILL.md +2 -2
  19. package/skills/nodefony-browser/SKILL.md +11 -1
  20. package/skills/nodefony-browser/references/sondes.md +19 -0
  21. package/skills/nodefony-browser/scripts/audit.mjs +5 -2
  22. package/skills/nodefony-browser/scripts/inspect.mjs +69 -7
  23. package/skills/nodefony-browser/scripts/lib/actions.mjs +54 -3
  24. package/skills/nodefony-browser/scripts/lib/browser.mjs +18 -1
  25. package/skills/nodefony-browser/scripts/lib/probes.mjs +52 -3
  26. package/skills/nodefony-dev/SKILL.md +20 -15
  27. package/skills/nodefony-devops/references/dimensionnement.md +1 -1
  28. package/skills/nodefony-devops/references/frontal.md +5 -2
  29. package/skills/nodefony-devops/references/image.md +23 -15
  30. package/skills/nodefony-migrate-schema/references/verdicts.md +1 -0
  31. package/skills/nodefony-protect-route/SKILL.md +27 -32
  32. package/skills/nodefony-add-realtime-channel/references/binaire.md +0 -191
@@ -1,4 +1,4 @@
1
- // \0@oxc-project+runtime@0.157.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.158.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
- // \0@oxc-project+runtime@0.157.0/helpers/esm/decorateParam.js
1
+ // \0@oxc-project+runtime@0.158.0/helpers/esm/decorateParam.js
2
2
  function __decorateParam(paramIndex, decorator) {
3
3
  return function(target, key) {
4
4
  decorator(target, key, paramIndex);
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import DevkitService_default from "./nodefony/service/DevkitService.js";
4
4
  import DevkitController_default from "./nodefony/controllers/DevkitController.js";
5
5
  import McpController_default from "./nodefony/controllers/McpController.js";
6
6
  import ProfileSessionController_default from "./nodefony/controllers/ProfileSessionController.js";
7
- import __decorate from "./_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
7
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
8
8
  import { BUILTIN_MCP_TOOL_KEYS, builtinMcpTools, selectBuiltinMcpTools } from "./nodefony/mcp/tools.js";
9
9
  import { buildCard } from "./nodefony/src/card.js";
10
10
  import { Module, services } from "nodefony";
@@ -1,4 +1,4 @@
1
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
1
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
2
2
  import { Controller, controller, route } from "@nodefony/framework";
3
3
  // nodefony/controllers/DevkitController.ts
4
4
  /**
@@ -1,5 +1,5 @@
1
- import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js";
2
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
1
+ import __decorateParam from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorateParam.js";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
3
3
  import { Body, Controller, controller, route } from "@nodefony/framework";
4
4
  // nodefony/controllers/McpController.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
1
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
2
2
  import { Controller, controller, route } from "@nodefony/framework";
3
3
  // nodefony/controllers/ProfileSessionController.ts
4
4
  /**
@@ -4,7 +4,7 @@ import { PROFILE_SESSION_FACTOR } from "../src/profileSession.js";
4
4
  import { readShotTarget } from "../mcp/shotClient.js";
5
5
  import { buildCard } from "../src/card.js";
6
6
  import defaults from "../config/config.js";
7
- import __decorate from "../../_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
7
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
8
8
  import { ADMIN_DEFAULT_ROLE, Nodefony, ROLES_WITHIN, Service, extend, injectable } from "nodefony";
9
9
  import { defineMcpGate, defineTicketSessionGate, mcpDeclaredScopes } from "@nodefony/ai";
10
10
  // nodefony/service/DevkitService.ts
@@ -144,9 +144,9 @@ let DevkitService = class DevkitService extends Service {
144
144
  servesTraffic: () => kernel?.runProfile.servers === true,
145
145
  onShutdown: (listener) => {
146
146
  if (kernel === null) return () => {};
147
- kernel.prependOnceListener("onTerminate", listener);
147
+ kernel.once("onDrain", listener);
148
148
  return () => {
149
- kernel.removeListener("onTerminate", listener);
149
+ kernel.removeListener("onDrain", listener);
150
150
  };
151
151
  },
152
152
  profileRoute: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/devkit",
3
- "version": "10.0.0-beta.6",
3
+ "version": "10.0.0-beta.7",
4
4
  "type": "module",
5
5
  "description": "Outillage de développement d'une application Nodefony : carte de visite du projet et portes de découverte pour un agent de développement",
6
6
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
@@ -27,20 +27,20 @@
27
27
  "test:integration": "vitest run --config vitest.integration.config.ts"
28
28
  },
29
29
  "peerDependencies": {
30
- "@nodefony/ai": "^10.0.0-beta.6",
31
- "@nodefony/framework": "^10.0.0-beta.6",
32
- "@nodefony/http": "^10.0.0-beta.6",
30
+ "@nodefony/ai": "^10.0.0-beta.7",
31
+ "@nodefony/framework": "^10.0.0-beta.7",
32
+ "@nodefony/http": "^10.0.0-beta.7",
33
33
  "lighthouse": "^13.6.0",
34
- "nodefony": "^10.0.0-beta.6",
34
+ "nodefony": "^10.0.0-beta.7",
35
35
  "playwright": "^1.50.0",
36
36
  "zod": "^4.6.7"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@modelcontextprotocol/sdk": "1.32.2",
40
- "@nodefony/ai": "^10.0.0-beta.6",
41
- "@nodefony/framework": "^10.0.0-beta.6",
42
- "@nodefony/http": "^10.0.0-beta.6",
43
- "nodefony": "^10.0.0-beta.6"
40
+ "@nodefony/ai": "^10.0.0-beta.7",
41
+ "@nodefony/framework": "^10.0.0-beta.7",
42
+ "@nodefony/http": "^10.0.0-beta.7",
43
+ "nodefony": "^10.0.0-beta.7"
44
44
  },
45
45
  "dependencies": {
46
46
  "axe-core": "4.14.0",
@@ -59,7 +59,7 @@
59
59
  "url": "git+https://github.com/nodefony/nodefony-core.git",
60
60
  "directory": "src/packages/@nodefony/devkit"
61
61
  },
62
- "homepage": "https://nodefony.github.io/nodefony-core/",
62
+ "homepage": "https://nodefony.net/",
63
63
  "bugs": {
64
64
  "url": "https://github.com/nodefony/nodefony-core/issues"
65
65
  },
@@ -77,6 +77,6 @@
77
77
  "esm"
78
78
  ],
79
79
  "engines": {
80
- "node": ">=24.14.0"
80
+ "node": "^24.16.0 || >=26.0.0"
81
81
  }
82
82
  }
@@ -0,0 +1,179 @@
1
+ ---
2
+ name: nodefony-add-grpc-service
3
+ metadata:
4
+ version: 1.0.0
5
+ description: >
6
+ Sert un service gRPC dans une application Nodefony — contrat `.proto`, code généré par `buf`,
7
+ contrôleur `GrpcController` marqué `@GrpcService`, déclaré par `@controllers` — à charger AVANT
8
+ d'écrire le contrat ou le contrôleur. Porte les paquets à installer, les pièges de génération,
9
+ ce que le framework décide à ta place (ports, refus 16/7, erreurs, arrêt, tailles) et un
10
+ script qui appelle la méthode depuis n'importe quel système pour prouver qu'elle répond.
11
+ Déclencheurs : "service gRPC", "exposer un contrat protobuf", "un client Go doit appeler
12
+ l'application", "gRPC-Web", "Connect", "buf generate", "@GrpcService", "GrpcController",
13
+ "appeler une méthode gRPC", "UNAUTHENTICATED", "UNIMPLEMENTED", "flux gRPC".
14
+ ---
15
+
16
+ # add-grpc-service — un contrat typé, servi par le pipeline
17
+
18
+ > 🧭 **Tu es arrivé ici directement ? Charge aussi `nodefony-dev`** — il porte la conduite
19
+ > commune (par où commencer, comment prouver que c'est fait). Cette page-ci ne couvre QUE gRPC.
20
+ >
21
+ > La référence complète est INSTALLÉE : `node_modules/@nodefony/framework/docs/grpc.md` (le
22
+ > pourquoi de chaque choix, les tables de codes, les limites). Une commande la retrouve, ligne
23
+ > comprise :
24
+ > `node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs grpc`.
25
+
26
+ > ⚖️ **La confiance n'exclut pas le contrôle.** Un contrôleur qui compile n'est pas un service
27
+ > qui répond. Le juge est l'appel réel (§ Prouver).
28
+
29
+ ## Ce qu'il faut savoir avant d'écrire
30
+
31
+ - **Une méthode gRPC est une route.** Le pare-feu, `@IsGranted`, l'identité (`this.user`) et le
32
+ journal valent comme sur toute route. N'ouvre JAMAIS un second serveur (`@grpc/grpc-js` et son
33
+ `bindAsync`) : il contournerait le pare-feu et les droits.
34
+ - **Trois protocoles, servis d'office** : gRPC (HTTP/2 seulement : port 5152 en TLS), gRPC-Web et
35
+ Connect (aussi en HTTP/1.1 : port 5151). Un navigateur et `curl` passent par Connect.
36
+ - **Surface expérimentale** : sa forme peut changer en version mineure, toujours annoncée.
37
+
38
+ ## Le geste
39
+
40
+ ### 1. Les paquets
41
+
42
+ ```bash
43
+ npm i @connectrpc/connect @connectrpc/connect-node @bufbuild/protobuf
44
+ npm i -D @bufbuild/buf @bufbuild/protoc-gen-es
45
+ ```
46
+
47
+ Le runtime en `dependencies` (une installation de production doit l'avoir), le générateur en
48
+ développement. Si npm annonce que le script d'installation de `@bufbuild/buf` est bloqué
49
+ (`allowScripts`), ce n'est pas un échec : `buf` fonctionne sans.
50
+
51
+ ### 2. Le contrat — `proto/<paquet>/v1/<nom>.proto`
52
+
53
+ ```proto
54
+ edition = "2024";
55
+
56
+ package greet.v1;
57
+
58
+ service GreetService {
59
+ rpc Greet(GreetRequest) returns (GreetResponse);
60
+ }
61
+
62
+ message GreetRequest {
63
+ string name = 1;
64
+ }
65
+
66
+ message GreetResponse {
67
+ string greeting = 1;
68
+ }
69
+ ```
70
+
71
+ Le dossier suit le paquet (`greet/v1/` pour `greet.v1`) : c'est ce que vérifie `buf lint`.
72
+
73
+ ### 3. La génération — deux fichiers à la racine, à côté de `proto/`
74
+
75
+ ```yaml
76
+ # buf.yaml
77
+ version: v2
78
+ modules:
79
+ - path: proto
80
+ lint:
81
+ use: [STANDARD]
82
+ breaking:
83
+ use: [FILE]
84
+ ```
85
+
86
+ ```yaml
87
+ # buf.gen.yaml
88
+ version: v2
89
+ inputs:
90
+ - directory: proto
91
+ plugins:
92
+ - local: protoc-gen-es
93
+ out: nodefony/gen
94
+ opt: target=ts
95
+ ```
96
+
97
+ ```bash
98
+ npx buf lint
99
+ npx buf generate
100
+ ```
101
+
102
+ `nodefony/gen/greet/v1/greet_pb.ts` est **commité** et jamais édité : régénérer après chaque
103
+ changement du contrat. Nodefony ne génère rien au build ni au démarrage.
104
+
105
+ ### 4. Le contrôleur — `nodefony/controllers/GreetController.ts`
106
+
107
+ ```ts
108
+ import { GrpcController, GrpcService } from "@nodefony/framework/grpc";
109
+ import type { ContextType } from "@nodefony/http";
110
+ import { GreetService } from "../gen/greet/v1/greet_pb";
111
+ import type { GreetRequest } from "../gen/greet/v1/greet_pb";
112
+
113
+ @GrpcService(GreetService)
114
+ export class GreetController extends GrpcController {
115
+ constructor(context: ContextType) {
116
+ super("GreetController", context);
117
+ }
118
+
119
+ greet(request: GreetRequest) {
120
+ return { greeting: `Bonjour, ${request.name} !` };
121
+ }
122
+ }
123
+ ```
124
+
125
+ Une méthode par méthode du contrat, nom en minuscule initiale (`Greet` → `greet`). Une méthode
126
+ manquante ou mal typée ne compile pas. La logique métier va dans un service injecté
127
+ (`@Inject("nom")` au constructeur — skill `nodefony-add-service`).
128
+
129
+ ### 5. La déclaration — `index.ts` de l'application
130
+
131
+ Ajouter la classe à la liste existante : `@controllers([HomeController, GreetController])`.
132
+ Sans cela, la classe compile et aucune route n'existe.
133
+
134
+ ## Ce que le framework décide pour toi
135
+
136
+ | Sujet | Ce qui se passe | Ton geste |
137
+ | ---------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
138
+ | Droits | `@IsGranted` sur la méthode ; refus sans identité → 16 `UNAUTHENTICATED`, identifié → 7 `PERMISSION_DENIED` | rien : ne traduis pas toi-même |
139
+ | Erreurs | `ConnectError` part telle quelle ; `HttpError` traduite ; toute autre exception → 13 `INTERNAL` | lever `new ConnectError(msg, Code.X)` pour choisir |
140
+ | Flux | traduction des erreurs avant le premier message seulement ; arrêt du serveur → 14 ; identité révoquée → 16 | passer `ctx.signal` à tes sources |
141
+ | Tailles | 4 Mio par message, reçu ou émis | section `grpc` de la config http pour changer |
142
+ | Échéance | unaire : la plus courte du client et du serveur → 4 ; flux : aucune | rien |
143
+ | Décorateurs HTTP | `@Body`, `@UseSession`, `@Csp`, `@Idempotent`… sur une méthode du contrat ARRÊTENT le démarrage | les retirer : le message EST le paramètre |
144
+
145
+ ## Prouver
146
+
147
+ Application démarrée (`npx nodefony development`) :
148
+
149
+ ```bash
150
+ npx nodefony inspect routes --json
151
+ node node_modules/@nodefony/devkit/skills/nodefony-add-grpc-service/scripts/call.mjs greet.v1.GreetService/Greet --field name=Ada
152
+ ```
153
+
154
+ - `inspect routes` doit montrer `POST /greet.v1.GreetService/Greet`. Absente : la classe n'est
155
+ pas dans `@controllers`, ou le `dist` est périmé.
156
+ - `call.mjs` rend `HTTP 200` et le message — ou le code gRPC d'un refus (`unauthenticated`…).
157
+ Il passe sous Windows, sans `curl` ni guillemets imbriqués. Message riche : `--file message.json`.
158
+ Jeton : `--header authorization=Bearer <jeton>`. Codes de sortie : 0 réponse, 1 refus,
159
+ 64 mauvais usage, 69 application injoignable.
160
+ - `npm run typecheck` ne prouve que la forme ; seul l'appel prouve le service.
161
+
162
+ ## Pièges
163
+
164
+ | Symptôme | Cause | Geste |
165
+ | ------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
166
+ | `Cannot find package '@connectrpc/connect-node'` au démarrage | paquet non installé | `npm i @connectrpc/connect @connectrpc/connect-node @bufbuild/protobuf` |
167
+ | `buf generate` : `protoc-gen-es` introuvable | lancé hors de l'application, ou générateur non installé | le lancer à la racine de l'application, après `npm i -D @bufbuild/protoc-gen-es` |
168
+ | Un client gRPC natif échoue tout de suite en `INTERNAL` | il vise 5151, qui est en HTTP/1.1 | viser 5152 (TLS) |
169
+ | `unimplemented` sur un chemin qui semble juste | casse ou paquet faux dans le chemin (`/greet.v1.GreetService/Greet`) | recopier le chemin depuis `inspect routes` |
170
+ | La méthode tourne encore après l'arrêt | elle n'écoute pas `ctx.signal` | le passer à la base, à l'abonnement, à `fetch` |
171
+
172
+ ## Voisins
173
+
174
+ | Besoin | Skill |
175
+ | ------------------------------------- | ------------------------------- |
176
+ | La logique métier du service | `nodefony-add-service` |
177
+ | Réserver une méthode à un rôle | `nodefony-protect-route` |
178
+ | Un flux temps réel vers le navigateur | `nodefony-add-realtime-channel` |
179
+ | Le service ne démarre pas | `nodefony-troubleshoot` |
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Appelle une méthode gRPC d'une application Nodefony, en protocole Connect
4
+ * (JSON sur HTTP) — la preuve qu'un service répond, sans `curl` ni guillemets.
5
+ *
6
+ * `curl -d '{"name":"Ada"}'` ne passe ni dans cmd.exe ni dans Windows PowerShell,
7
+ * et `grpcurl` n'est pas installé partout. Ce script n'emploie que `fetch` de
8
+ * Node : il marche à l'identique sur les trois plateformes, et il rend le CODE
9
+ * gRPC du refus (`unauthenticated`, `permission_denied`…), pas seulement un
10
+ * statut HTTP.
11
+ *
12
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-add-grpc-service/scripts/call.mjs greet.v1.GreetService/Greet --field name=Ada
13
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-add-grpc-service/scripts/call.mjs greet.v1.GreetService/Greet --file body.json
14
+ * `@option` --field - un champ du message, `nom=valeur` (répétable ; la valeur est une chaîne)
15
+ * `@option` --file - le message entier, lu dans un fichier JSON
16
+ * `@option` --header - un en-tête, `nom=valeur` (répétable), par exemple `authorization=Bearer …`
17
+ * `@option` --url - l'adresse de l'application (défaut http://127.0.0.1:5151)
18
+ * `@option` --json - la réponse brute, sérialisée, pour rechaîner
19
+ * `@requires` l'application démarrée
20
+ * `@output` le statut HTTP, puis le message reçu ou le code gRPC du refus
21
+ */
22
+
23
+ import { readFileSync, realpathSync } from "node:fs";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ /** L'adresse d'une application de développement, sans TLS. */
27
+ const DEFAULT_URL = "http://127.0.0.1:5151";
28
+
29
+ /** Une méthode gRPC : `paquet.Service/Méthode`, sans barre de tête. */
30
+ const METHOD_RE = /^[A-Za-z_][\w.]*\.[A-Za-z_]\w*\/[A-Za-z_]\w*$/u;
31
+
32
+ const USAGE = `
33
+ Appelle une méthode gRPC d'une application Nodefony, en Connect (JSON sur HTTP).
34
+
35
+ call.mjs <paquet.Service/Méthode> [--field nom=valeur]... le message, champ par champ
36
+ call.mjs <paquet.Service/Méthode> --file message.json le message, depuis un fichier
37
+ call.mjs ... --header authorization=Bearer <jeton> un en-tête (répétable)
38
+ call.mjs ... --url http://127.0.0.1:5151 l'application visée
39
+ call.mjs ... --json la réponse brute
40
+
41
+ Codes de sortie : 0 réponse · 1 refus ou erreur gRPC · 64 mauvais usage · 69 application injoignable
42
+ `.trim();
43
+
44
+ /**
45
+ * Coupe `nom=valeur` au PREMIER `=` : une valeur peut en contenir (jeton base64).
46
+ *
47
+ * @param {string} pair - la paire saisie.
48
+ * @returns {[string, string] | null} le nom et la valeur, ou `null` si la forme est fausse.
49
+ */
50
+ const splitPair = (pair) => {
51
+ const at = pair.indexOf("=");
52
+ if (at <= 0) return null;
53
+ return [pair.slice(0, at), pair.slice(at + 1)];
54
+ };
55
+
56
+ /**
57
+ * Lit la ligne de commande.
58
+ *
59
+ * @param {string[]} argv - les arguments, sans `node` ni le script.
60
+ * @returns {{ method: string, url: string, body: Record<string, unknown>, headers: Record<string, string>, json: boolean } | { error: string }}
61
+ * la demande, ou l'erreur d'usage à afficher.
62
+ */
63
+ export function parseArgs(argv) {
64
+ let method = null;
65
+ let url = DEFAULT_URL;
66
+ let json = false;
67
+ let file = null;
68
+ const fields = Object.create(null);
69
+ const headers = Object.create(null);
70
+ for (let i = 0; i < argv.length; i++) {
71
+ const arg = argv[i];
72
+ if (arg === "--json") {
73
+ json = true;
74
+ continue;
75
+ }
76
+ if (
77
+ arg === "--field" ||
78
+ arg === "--header" ||
79
+ arg === "--url" ||
80
+ arg === "--file"
81
+ ) {
82
+ const value = argv[++i];
83
+ if (value === undefined) return { error: `${arg} attend une valeur` };
84
+ if (arg === "--url") url = value;
85
+ else if (arg === "--file") file = value;
86
+ else {
87
+ const pair = splitPair(value);
88
+ if (pair === null)
89
+ return { error: `${arg} attend nom=valeur, reçu « ${value} »` };
90
+ (arg === "--field" ? fields : headers)[pair[0]] = pair[1];
91
+ }
92
+ continue;
93
+ }
94
+ if (arg.startsWith("-")) return { error: `option inconnue : ${arg}` };
95
+ if (method !== null)
96
+ return { error: `une seule méthode par appel (reçu « ${arg} »)` };
97
+ method = arg.replace(/^\/+/u, "");
98
+ }
99
+ if (method === null) return { error: "la méthode à appeler manque" };
100
+ if (!METHOD_RE.test(method)) {
101
+ return {
102
+ error: `méthode « ${method} » : la forme est paquet.Service/Méthode, comme greet.v1.GreetService/Greet`,
103
+ };
104
+ }
105
+ let body = fields;
106
+ if (file !== null) {
107
+ if (Object.keys(fields).length !== 0)
108
+ return { error: "--file et --field s'excluent" };
109
+ try {
110
+ body = JSON.parse(readFileSync(file, "utf8"));
111
+ } catch (e) {
112
+ return {
113
+ error: `--file ${file} : ${e instanceof Error ? e.message : String(e)}`,
114
+ };
115
+ }
116
+ }
117
+ return { method, url: url.replace(/\/+$/u, ""), body, headers, json };
118
+ }
119
+
120
+ /**
121
+ * Envoie l'appel Connect unaire et lit la réponse.
122
+ *
123
+ * @param {{ method: string, url: string, body: unknown, headers: Record<string, string> }} request - la demande.
124
+ * @returns {Promise<{ status: number, payload: unknown }>} le statut HTTP et le corps décodé (texte brut s'il n'est pas JSON).
125
+ */
126
+ export async function callConnect(request) {
127
+ const response = await fetch(`${request.url}/${request.method}`, {
128
+ method: "POST",
129
+ headers: {
130
+ ...request.headers,
131
+ "content-type": "application/json",
132
+ "connect-protocol-version": "1",
133
+ },
134
+ body: JSON.stringify(request.body),
135
+ });
136
+ const text = await response.text();
137
+ let payload = text;
138
+ try {
139
+ payload = text === "" ? {} : JSON.parse(text);
140
+ } catch {
141
+ // corps non JSON (une page d'erreur HTML) : rendu tel quel
142
+ }
143
+ return { status: response.status, payload };
144
+ }
145
+
146
+ /**
147
+ * Le code gRPC d'un refus Connect (`{"code":"unauthenticated"}`), ou `null`.
148
+ *
149
+ * @param {unknown} payload - le corps décodé.
150
+ * @returns {string | null} le code textuel.
151
+ */
152
+ const connectCode = (payload) =>
153
+ payload !== null &&
154
+ typeof payload === "object" &&
155
+ "code" in payload &&
156
+ typeof payload.code === "string"
157
+ ? payload.code
158
+ : null;
159
+
160
+ /**
161
+ * Exécute la commande.
162
+ *
163
+ * @param {string[]} argv - les arguments.
164
+ * @returns {Promise<number>} le code de sortie.
165
+ */
166
+ export async function main(argv) {
167
+ const request = parseArgs(argv);
168
+ if ("error" in request) {
169
+ process.stderr.write(`${request.error}\n\n${USAGE}\n`);
170
+ return 64;
171
+ }
172
+ let result;
173
+ try {
174
+ result = await callConnect(request);
175
+ } catch (e) {
176
+ const cause =
177
+ e instanceof Error && e.cause instanceof Error
178
+ ? e.cause.message
179
+ : String(e);
180
+ process.stderr.write(
181
+ `L'application ne répond pas sur ${request.url} (${cause}).\n` +
182
+ "Démarre-la : npx nodefony development — puis relance l'appel.\n",
183
+ );
184
+ return 69;
185
+ }
186
+ const { status, payload } = result;
187
+ if (request.json) {
188
+ process.stdout.write(`${JSON.stringify({ status, payload })}\n`);
189
+ } else if (status === 200) {
190
+ process.stdout.write(
191
+ `HTTP 200 · ${request.method}\n${JSON.stringify(payload, null, 2)}\n`,
192
+ );
193
+ } else {
194
+ const code = connectCode(payload);
195
+ process.stdout.write(
196
+ code === null
197
+ ? `HTTP ${status} · ${request.method} — pas une réponse gRPC :\n${typeof payload === "string" ? payload : JSON.stringify(payload, null, 2)}\n`
198
+ : `HTTP ${status} · ${request.method} — refus gRPC « ${code} »${
199
+ typeof payload === "object" &&
200
+ payload !== null &&
201
+ "message" in payload
202
+ ? ` : ${String(payload.message)}`
203
+ : ""
204
+ }\n`,
205
+ );
206
+ }
207
+ return status === 200 ? 0 : 1;
208
+ }
209
+
210
+ // Exécuté directement (et non importé par son test) : rendre le code. Chemins
211
+ // RÉELS des deux côtés — lancé par un lien (`/var/…` sous macOS), la
212
+ // comparaison brute échouait et le script ne faisait rien.
213
+ if (
214
+ process.argv[1] !== undefined &&
215
+ realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
216
+ ) {
217
+ process.exitCode = await main(process.argv.slice(2));
218
+ }