@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.
- 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 +1 -1
- package/dist/nodefony/controllers/DevkitController.js +1 -1
- package/dist/nodefony/controllers/McpController.js +2 -2
- package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
- package/dist/nodefony/service/DevkitService.js +3 -3
- package/package.json +11 -11
- package/skills/nodefony-add-grpc-service/SKILL.md +179 -0
- package/skills/nodefony-add-grpc-service/scripts/call.mjs +218 -0
- package/skills/nodefony-add-realtime-binary/SKILL.md +301 -0
- package/skills/nodefony-add-realtime-binary/references/messages.md +20 -0
- package/skills/nodefony-add-realtime-binary/references/mesures.md +59 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +408 -326
- package/skills/nodefony-add-realtime-channel/references/contrat.md +22 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +21 -2
- package/skills/nodefony-add-realtime-presence/SKILL.md +328 -0
- package/skills/nodefony-authentication/SKILL.md +2 -2
- package/skills/nodefony-browser/SKILL.md +11 -1
- package/skills/nodefony-browser/references/sondes.md +19 -0
- package/skills/nodefony-browser/scripts/audit.mjs +5 -2
- package/skills/nodefony-browser/scripts/inspect.mjs +69 -7
- package/skills/nodefony-browser/scripts/lib/actions.mjs +54 -3
- package/skills/nodefony-browser/scripts/lib/browser.mjs +18 -1
- package/skills/nodefony-browser/scripts/lib/probes.mjs +52 -3
- package/skills/nodefony-dev/SKILL.md +20 -15
- package/skills/nodefony-devops/references/dimensionnement.md +1 -1
- package/skills/nodefony-devops/references/frontal.md +5 -2
- package/skills/nodefony-devops/references/image.md +23 -15
- package/skills/nodefony-migrate-schema/references/verdicts.md +1 -0
- package/skills/nodefony-protect-route/SKILL.md +27 -32
- package/skills/nodefony-add-realtime-channel/references/binaire.md +0 -191
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// \0@oxc-project+runtime@0.
|
|
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);
|
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.
|
|
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.
|
|
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.
|
|
2
|
-
import __decorate from "../../_virtual/_@oxc-project_runtime@0.
|
|
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.
|
|
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.
|
|
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.
|
|
147
|
+
kernel.once("onDrain", listener);
|
|
148
148
|
return () => {
|
|
149
|
-
kernel.removeListener("
|
|
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.
|
|
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.
|
|
31
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
32
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
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.
|
|
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.
|
|
41
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
42
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
43
|
-
"nodefony": "^10.0.0-beta.
|
|
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.
|
|
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": "
|
|
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
|
+
}
|