@nodefony/http 10.0.0-beta.5 → 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 (130) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +30 -26
  5. package/dist/nodefony/command/proxyGenerateCommand.js +2 -2
  6. package/dist/nodefony/config/config.js +29 -4
  7. package/dist/nodefony/grpc/GrpcCall.js +298 -0
  8. package/dist/nodefony/grpc/GrpcEndpoint.js +528 -0
  9. package/dist/nodefony/grpc/index.js +4 -0
  10. package/dist/nodefony/interfaces/IRpcCall.js +1 -0
  11. package/dist/nodefony/service/ProfilerAdminApi.js +5 -2
  12. package/dist/nodefony/service/audit-logger.js +52 -6
  13. package/dist/nodefony/service/error-renderer.js +47 -13
  14. package/dist/nodefony/service/http-kernel.js +306 -49
  15. package/dist/nodefony/service/pretty-request-logger.js +5 -3
  16. package/dist/nodefony/service/request-logger.js +21 -6
  17. package/dist/nodefony/service/reverse-proxy.js +8 -3
  18. package/dist/nodefony/service/servers/clientError.js +53 -4
  19. package/dist/nodefony/service/servers/server-h2c.js +6 -6
  20. package/dist/nodefony/service/servers/server-http.js +6 -4
  21. package/dist/nodefony/service/servers/server-https.js +14 -7
  22. package/dist/nodefony/service/servers/server-static.js +1 -6
  23. package/dist/nodefony/service/servers/server-websocket-secure.js +7 -11
  24. package/dist/nodefony/service/servers/server-websocket.js +7 -11
  25. package/dist/nodefony/service/servers/serverShutdown.js +43 -6
  26. package/dist/nodefony/service/sessions/sessions-service.js +7 -7
  27. package/dist/nodefony/service/upload/upload-service.js +2 -2
  28. package/dist/nodefony/src/assets/prebuiltUi.js +23 -5
  29. package/dist/nodefony/src/context/Context.js +12 -1
  30. package/dist/nodefony/src/context/http/ByteStream.js +364 -0
  31. package/dist/nodefony/src/context/http/HttpContext.js +139 -32
  32. package/dist/nodefony/src/context/http/Request.js +111 -22
  33. package/dist/nodefony/src/context/http/Response.js +459 -37
  34. package/dist/nodefony/src/context/http/SseStream.js +83 -148
  35. package/dist/nodefony/src/context/http/StreamLifecycle.js +582 -0
  36. package/dist/nodefony/src/context/http/parser.js +29 -11
  37. package/dist/nodefony/src/context/http/urlFastPath.js +80 -1
  38. package/dist/nodefony/src/context/http/webResponse.js +56 -0
  39. package/dist/nodefony/src/context/http2/Request.js +1 -1
  40. package/dist/nodefony/src/context/http2/Response.js +55 -12
  41. package/dist/nodefony/src/context/requestTrace.js +6 -2
  42. package/dist/nodefony/src/context/rpcOutcome.js +20 -0
  43. package/dist/nodefony/src/context/transport.js +20 -1
  44. package/dist/nodefony/src/context/websocket/Response.js +15 -3
  45. package/dist/nodefony/src/context/websocket/WebsocketContext.js +86 -18
  46. package/dist/nodefony/src/context/websocket/websocketUrl.js +7 -0
  47. package/dist/nodefony/src/context/websocket/wsBackpressure.js +15 -15
  48. package/dist/nodefony/src/context/websocket/wsFrameRate.js +71 -0
  49. package/dist/nodefony/src/context/websocket/wsLogContent.js +55 -17
  50. package/dist/nodefony/src/cookies/cookie.js +57 -14
  51. package/dist/nodefony/src/errors/httpError.js +58 -25
  52. package/dist/nodefony/src/grpc/cors.js +59 -0
  53. package/dist/nodefony/src/grpc/limits.js +16 -0
  54. package/dist/nodefony/src/grpc/refusal.js +271 -0
  55. package/dist/nodefony/src/profiler/Profiler.js +14 -1
  56. package/dist/nodefony/src/profiler/aggregate.js +2 -2
  57. package/dist/nodefony/src/proxy/forward.js +109 -9
  58. package/dist/nodefony/src/proxy/generateProxyConfig.js +97 -17
  59. package/dist/nodefony/src/proxy/rules.js +29 -14
  60. package/dist/nodefony/src/servers/http2Goaway.js +76 -0
  61. package/dist/nodefony/src/servers/http2Limits.js +41 -15
  62. package/dist/nodefony/src/servers/http2StreamGrace.js +100 -0
  63. package/dist/nodefony/src/servers/receiveDeadline.js +177 -0
  64. package/dist/nodefony/src/servers/upgradeDispatch.js +62 -4
  65. package/dist/types/index.d.ts +11 -2
  66. package/dist/types/nodefony/config/config.d.ts +14 -0
  67. package/dist/types/nodefony/grpc/GrpcCall.d.ts +142 -0
  68. package/dist/types/nodefony/grpc/GrpcEndpoint.d.ts +98 -0
  69. package/dist/types/nodefony/grpc/index.d.ts +15 -0
  70. package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
  71. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +14 -0
  72. package/dist/types/nodefony/interfaces/IRequest.d.ts +2 -1
  73. package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +10 -0
  74. package/dist/types/nodefony/interfaces/IRouting.d.ts +19 -0
  75. package/dist/types/nodefony/interfaces/IRpcCall.d.ts +83 -0
  76. package/dist/types/nodefony/interfaces/ISecurity.d.ts +27 -2
  77. package/dist/types/nodefony/interfaces/IUpload.d.ts +51 -4
  78. package/dist/types/nodefony/interfaces/index.d.ts +2 -1
  79. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +2 -1
  80. package/dist/types/nodefony/service/audit-logger.d.ts +33 -3
  81. package/dist/types/nodefony/service/error-renderer.d.ts +27 -5
  82. package/dist/types/nodefony/service/http-kernel.d.ts +36 -3
  83. package/dist/types/nodefony/service/request-logger.d.ts +10 -0
  84. package/dist/types/nodefony/service/servers/clientError.d.ts +3 -2
  85. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +7 -5
  86. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +9 -4
  87. package/dist/types/nodefony/src/context/Context.d.ts +9 -1
  88. package/dist/types/nodefony/src/context/http/ByteStream.d.ts +109 -0
  89. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +99 -3
  90. package/dist/types/nodefony/src/context/http/Request.d.ts +46 -9
  91. package/dist/types/nodefony/src/context/http/Response.d.ts +188 -8
  92. package/dist/types/nodefony/src/context/http/SseStream.d.ts +33 -8
  93. package/dist/types/nodefony/src/context/http/StreamLifecycle.d.ts +106 -0
  94. package/dist/types/nodefony/src/context/http/parser.d.ts +17 -3
  95. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +40 -0
  96. package/dist/types/nodefony/src/context/http/webResponse.d.ts +31 -0
  97. package/dist/types/nodefony/src/context/http2/Response.d.ts +40 -5
  98. package/dist/types/nodefony/src/context/requestTrace.d.ts +3 -1
  99. package/dist/types/nodefony/src/context/rpcOutcome.d.ts +11 -0
  100. package/dist/types/nodefony/src/context/transport.d.ts +17 -0
  101. package/dist/types/nodefony/src/context/websocket/Response.d.ts +6 -1
  102. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +48 -0
  103. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +4 -0
  104. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +12 -2
  105. package/dist/types/nodefony/src/context/websocket/wsFrameRate.d.ts +69 -0
  106. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +16 -8
  107. package/dist/types/nodefony/src/cookies/cookie.d.ts +20 -2
  108. package/dist/types/nodefony/src/errors/httpError.d.ts +29 -4
  109. package/dist/types/nodefony/src/grpc/cors.d.ts +32 -0
  110. package/dist/types/nodefony/src/grpc/limits.d.ts +14 -0
  111. package/dist/types/nodefony/src/grpc/refusal.d.ts +136 -0
  112. package/dist/types/nodefony/src/profiler/Profiler.d.ts +11 -1
  113. package/dist/types/nodefony/src/proxy/forward.d.ts +37 -2
  114. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +15 -2
  115. package/dist/types/nodefony/src/proxy/rules.d.ts +20 -0
  116. package/dist/types/nodefony/src/servers/http2Goaway.d.ts +41 -0
  117. package/dist/types/nodefony/src/servers/http2Limits.d.ts +27 -14
  118. package/dist/types/nodefony/src/servers/http2StreamGrace.d.ts +34 -0
  119. package/dist/types/nodefony/src/servers/receiveDeadline.d.ts +77 -0
  120. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +41 -1
  121. package/docs/byte-stream.md +406 -0
  122. package/docs/cookies.md +13 -3
  123. package/docs/diagnostics-channel.md +45 -1
  124. package/docs/index.md +8 -0
  125. package/docs/reverse-proxy.md +15 -3
  126. package/docs/servers.md +67 -43
  127. package/docs/session.md +3 -3
  128. package/docs/sse.md +27 -20
  129. package/docs/upload.md +43 -17
  130. package/package.json +38 -18
@@ -0,0 +1,528 @@
1
+ import HttpError from "../src/errors/httpError.js";
2
+ import { STREAM_STALL_TIMEOUT_MS, disarmResponseTimeout, openStreamLifecycle, resolveStreamTarget } from "../src/context/http/StreamLifecycle.js";
3
+ import { GRPC_DEFAULT_MAX_MESSAGE_BYTES } from "../src/grpc/limits.js";
4
+ import { applicationErrorCode } from "../src/grpc/refusal.js";
5
+ import { RPC_EXPOSED_HEADERS } from "../src/grpc/cors.js";
6
+ import DefaultErrorRenderer from "../service/error-renderer.js";
7
+ import { GrpcCall } from "./GrpcCall.js";
8
+ import { Code, ConnectError } from "@connectrpc/connect";
9
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
10
+ import { createRegistry } from "@bufbuild/protobuf";
11
+ // nodefony/grpc/GrpcEndpoint.ts
12
+ /**
13
+ * Levé par le repli de l'adaptateur (`fallback`), qui l'appelle de façon
14
+ * SYNCHRONE quand aucune méthode ne correspond au chemin exact — casse
15
+ * modifiée, le Router étant insensible à la casse. Une seule instance : le
16
+ * repli ne paie aucune allocation, et `serve` le reconnaît par identité.
17
+ */
18
+ const UNROUTED = /* @__PURE__ */ new Error("gRPC : aucune méthode à ce chemin");
19
+ /**
20
+ * Repli de l'adaptateur : rien n'est écrit ici. Le refus remonte au pipeline,
21
+ * qui l'écrit au format du protocole comme tout chemin inconnu (ADR-0026 D13
22
+ * point 2) — un seul écrivain des refus. Le repli par défaut de Connect, un
23
+ * 404 nu terminé par une trame DATA, est lu `INTERNAL` par grpc-go
24
+ * (connectrpc/connect-es#1882).
25
+ */
26
+ function unrouted() {
27
+ throw UNROUTED;
28
+ }
29
+ /**
30
+ * Défauts de la section `grpc`, quand le contexte n'a pas de noyau http (banc
31
+ * hors serveur) : les trois protocoles (défaut de l'adaptateur,
32
+ * `router.d.ts`) et les bornes de Nodefony — jamais celles de l'adaptateur,
33
+ * qui n'en pose aucune.
34
+ */
35
+ const DEFAULT_SETTINGS = Object.freeze({
36
+ grpc: true,
37
+ grpcWeb: true,
38
+ connect: true,
39
+ readMaxBytes: GRPC_DEFAULT_MAX_MESSAGE_BYTES,
40
+ writeMaxBytes: GRPC_DEFAULT_MAX_MESSAGE_BYTES,
41
+ requireConnectProtocolHeader: false
42
+ });
43
+ /**
44
+ * Type retenu pour la politique d'en-têtes d'un appel sans `Content-Type` (GET
45
+ * Connect, dont le codage passe en paramètre d'URL).
46
+ */
47
+ const RPC_RESPONSE_TYPE = "application/proto";
48
+ /**
49
+ * Point d'entrée gRPC d'un service : possède l'adaptateur Connect
50
+ * (`connectNodeAdapter`) et lui confie la requête et la réponse BRUTES de Node
51
+ * d'un contexte déjà passé par le pipeline (pare-feu, gardes).
52
+ *
53
+ * L'adaptateur est construit au premier appel, depuis la section `grpc` de la
54
+ * configuration http. Il reçoit la fiche d'appel ({@link GrpcCall}) par son
55
+ * option `contextValues`, qu'il appelle de façon SYNCHRONE avant toute
56
+ * attente (`connect-node-adapter.js#nodeRequestHandler`) : la cible passe par
57
+ * une case lue à cet instant — ni ALS, ni promesse, ni écouteur de plus.
58
+ *
59
+ * Les méthodes s'inscrivent une à une par `router.rpc`, SANS options : des
60
+ * options passées à l'inscription rallumeraient gRPC et gRPC-Web coupés par la
61
+ * configuration (connectrpc/connect-es#1835). La restriction de protocoles
62
+ * vit sur l'adaptateur seul.
63
+ *
64
+ * @experimental Surface gRPC, ADR-0026 D11.
65
+ */
66
+ var GrpcEndpoint = class {
67
+ /** Le contrat servi (descripteur généré par `buf`). */
68
+ service;
69
+ /** Cible relue à chaque appel (`@Scope("request")`). */
70
+ perCall;
71
+ #handler = null;
72
+ /** Case synchrone : la cible de l'appel en cours de remise à l'adaptateur. */
73
+ #pendingTarget = null;
74
+ /** Case synchrone : le contexte de l'appel en cours de remise. */
75
+ #pendingContext = null;
76
+ /**
77
+ * Option `contextValues` : une fiche par appel, rien sur le chemin du 404.
78
+ * Accrochée au contexte (`HttpContext.rpc`, ADR-0026 D10) : à l'échéance du
79
+ * délai de réponse, le noyau la termine au lieu d'écrire un 503.
80
+ */
81
+ #contextValues = () => {
82
+ const context = this.#pendingContext;
83
+ const call = new GrpcCall(this.#pendingTarget, context);
84
+ if (context !== null) context.rpc = call;
85
+ return call;
86
+ };
87
+ /**
88
+ * Chemins des méthodes en flux (serveur, client, bidirectionnel) : elles
89
+ * s'ouvrent par le cycle de vie d'un flux. Tout autre chemin — unaire, ou
90
+ * inconnu, que l'adaptateur refusera sans rien écrire — garde son délai de
91
+ * réponse.
92
+ */
93
+ #streamingPaths;
94
+ constructor(service, options = {}) {
95
+ this.service = service;
96
+ this.perCall = options.perCall ?? false;
97
+ this.#streamingPaths = streamingPathsOf(service);
98
+ }
99
+ /**
100
+ * Remet la requête du contexte à l'adaptateur, qui écrit lui-même la
101
+ * réponse et ses trailers. Synchrone : rien n'est attendu ici.
102
+ *
103
+ * Avant de la remettre : le contexte est marqué répondu (`sended`), si bien
104
+ * que le pipeline n'attend aucune réponse de l'action.
105
+ *
106
+ * - Appel unaire : le délai de réponse de Nodefony reste armé, et termine
107
+ * l'appel en 4 `DEADLINE_EXCEEDED` par sa fiche (`HttpContext.rpc`,
108
+ * ADR-0026 D4 point 3).
109
+ * - Flux : aucune échéance. Il s'ouvre par le cycle de vie d'un flux
110
+ * (`openStreamLifecycle`, D4 point 4) — délai de réponse désarmé (à
111
+ * l'expiration il écrirait un 503 par-dessus l'adaptateur), place dans
112
+ * le budget de connexions longues par IP, client qui ne lit plus coupé
113
+ * par le balayage de blocage. Synchrone, sauf quand une session est à
114
+ * sauver : l'appel n'est remis qu'après.
115
+ *
116
+ * @param context - le contexte HTTP de la requête, pas encore répondu.
117
+ * @param target - l'objet qui implémente le service pour cet appel.
118
+ * @returns rien, ou la promesse de la remise quand une session est à sauver.
119
+ * @throws HttpError 500 quand la réponse est déjà partie, ou que son
120
+ * transport n'est pas reconnu.
121
+ * @throws HttpError 429 quand l'IP du client a déjà tous ses flux et sockets
122
+ * (`wsMaxConnectionsPerIp`) — rendu par le pipeline en 14 `UNAVAILABLE`.
123
+ * @throws HttpError 400 quand l'adaptateur refuse la requête avant d'avoir
124
+ * rien écrit (requête sans autorité `Host`) — la réponse revient alors au
125
+ * pipeline.
126
+ * @throws HttpError 404 quand aucune méthode ne correspond au chemin exact
127
+ * (casse modifiée) — rendu par le pipeline en 12 `UNIMPLEMENTED`.
128
+ */
129
+ serve(context, target) {
130
+ if (context.sended || context.finished || context.response.isHeaderSent()) throw new HttpError("gRPC : la réponse est déjà partie", 500);
131
+ const nodeResponse = context.response.response;
132
+ const streamTarget = resolveStreamTarget(context);
133
+ if (nodeResponse === null || streamTarget === null) throw new HttpError("gRPC : transport de réponse inconnu", 500, context);
134
+ const handler = this.#handler ?? this.#build(context, target);
135
+ if (!this.#streamingPaths.has(context.request.url.pathname)) {
136
+ context.sended = true;
137
+ this.#hand(context, target, handler, nodeResponse);
138
+ return;
139
+ }
140
+ disarmResponseTimeout(streamTarget);
141
+ const opened = openStreamLifecycle(context, {
142
+ label: "gRPC",
143
+ stallTimeout: STREAM_STALL_TIMEOUT_MS,
144
+ maxDuration: false,
145
+ foreignWriter: true
146
+ });
147
+ if (opened instanceof Promise) return opened.then((lifecycle) => this.#handStream(lifecycle, target, handler, nodeResponse));
148
+ this.#handStream(opened, target, handler, nodeResponse);
149
+ }
150
+ /** Remet un flux à l'adaptateur — sauf si le client est déjà parti. */
151
+ #handStream(lifecycle, target, handler, nodeResponse) {
152
+ if (lifecycle.closed) return;
153
+ this.#hand(lifecycle.context, target, handler, nodeResponse);
154
+ }
155
+ /**
156
+ * Remet la requête à l'adaptateur. La fiche d'appel est créée par
157
+ * `contextValues`, appelée de façon SYNCHRONE dans l'appel : les cases
158
+ * `#pending*` ne vivent que le temps de celui-ci.
159
+ *
160
+ * L'adaptateur écrit sur la réponse Node : ce que le pipeline a posé dans la
161
+ * réserve — transport, CORS et `Vary`, corrélation (`X-Request-Id`,
162
+ * `traceparent`), politique de sécurité, cookie de session — y passe d'abord
163
+ * (ADR-0026 D6). Node fusionne au `writeHead` de l'adaptateur, qui garde la
164
+ * main sur ce qu'il écrit lui-même (`content-type`).
165
+ */
166
+ #hand(context, target, handler, nodeResponse) {
167
+ if (context.response.hasHeader("access-control-allow-origin")) exposeRpcHeaders(context.response);
168
+ context.response.commitHeaders(context.request.request.headers["content-type"] ?? RPC_RESPONSE_TYPE);
169
+ this.#pendingTarget = target;
170
+ this.#pendingContext = context;
171
+ try {
172
+ handler(context.request.request, nodeResponse);
173
+ } catch (error) {
174
+ if (nodeResponse.headersSent) throw error;
175
+ context.sended = false;
176
+ context.rpc = null;
177
+ if (error === UNROUTED) throw HttpError.withoutStack("Not Found", 404, context);
178
+ context.log(error, "WARNING", "GRPC");
179
+ throw HttpError.withoutStack("gRPC : requête mal formée", 400, context);
180
+ } finally {
181
+ this.#pendingTarget = null;
182
+ this.#pendingContext = null;
183
+ }
184
+ }
185
+ /** Construit l'adaptateur, une fois, depuis la configuration http. */
186
+ #build(context, target) {
187
+ const settings = context.httpKernel?.options.grpc ?? DEFAULT_SETTINGS;
188
+ const shared = this.perCall ? null : target;
189
+ const handler = connectNodeAdapter({
190
+ grpc: settings.grpc,
191
+ grpcWeb: settings.grpcWeb,
192
+ connect: settings.connect,
193
+ readMaxBytes: settings.readMaxBytes,
194
+ writeMaxBytes: settings.writeMaxBytes,
195
+ requireConnectProtocolHeader: settings.requireConnectProtocolHeader,
196
+ contextValues: this.#contextValues,
197
+ requestGate: observeCall,
198
+ fallback: unrouted,
199
+ jsonOptions: { registry: contractRegistry(this.service) },
200
+ routes: (router) => registerMethods(router, this.service, shared)
201
+ });
202
+ this.#handler = handler;
203
+ return handler;
204
+ }
205
+ };
206
+ /** Les chemins `/<paquet.Service>/<Méthode>` des méthodes en flux du contrat. */
207
+ function streamingPathsOf(service) {
208
+ const paths = /* @__PURE__ */ new Set();
209
+ for (const method of service.methods) if (!isUnary(method)) paths.add(`/${service.typeName}/${method.name}`);
210
+ return paths;
211
+ }
212
+ /**
213
+ * Fin d'un appel à réponse unique — unaire (ADR-0026 D4, point 3), flux
214
+ * client : l'appel se termine dès que le serveur l'interrompt — délai de
215
+ * réponse échu (unaire), identité révoquée (D6) —, même si le gestionnaire ne
216
+ * lit pas son signal.
217
+ *
218
+ * - L'adaptateur tient l'échéance DU CLIENT et abaisse `ctx.signal`, mais
219
+ * attend la promesse du gestionnaire
220
+ * (`invoke-implementation.js#invokeUnaryImplementation`) : un gestionnaire
221
+ * sourd tiendrait l'appel ouvert sans fin.
222
+ * - Le délai du serveur est celui que Nodefony arme déjà sur toute requête,
223
+ * gardé sur une route unaire : à l'expiration, `HttpContext._onTimeout`
224
+ * appelle `GrpcCall.expire`, qui abaisse le signal et rejette la promesse
225
+ * rendue ici avec 4 `DEADLINE_EXCEEDED`. Aucun minuteur de plus.
226
+ * - Ce délai n'est PAS écrit dans l'en-tête d'échéance : le gestionnaire
227
+ * verrait par `ctx.timeoutMs()` une échéance que le client n'a pas envoyée
228
+ * (suite de conformité de Connect : « server echoed back a timeout but none
229
+ * was expected »).
230
+ * - Un résultat synchrone passe tel quel : ni promesse, ni suivi.
231
+ */
232
+ function settleUnary(result, ctx) {
233
+ if (!(result instanceof Promise)) return result;
234
+ return settlePending(result, ctx);
235
+ }
236
+ /** {@link settleUnary} d'une réponse attendue : suivie par la fiche d'appel. */
237
+ function settlePending(pending, ctx) {
238
+ const call = ctx.values;
239
+ if (!(call instanceof GrpcCall)) return pending;
240
+ return new Promise((resolve, reject) => {
241
+ call.attach(ctx, reject);
242
+ pending.then((value) => {
243
+ call.detach();
244
+ resolve(value);
245
+ }, (error) => {
246
+ call.detach();
247
+ reject(translateError(error, ctx));
248
+ });
249
+ });
250
+ }
251
+ /**
252
+ * Registre des types du contrat : son fichier et tout ce qu'il importe, types
253
+ * bien connus compris. Sans lui, un `google.protobuf.Any` ne se lit ni ne
254
+ * s'écrit en JSON (Connect et gRPC-Web JSON répondent `INVALID_ARGUMENT`) :
255
+ * le mappage JSON de proto3 exige de résoudre le type emballé. Construit une
256
+ * fois ; Connect ne le consulte que pour `Any` en JSON. Un type emballé hors
257
+ * du graphe d'imports du contrat reste non résolu en JSON — le binaire, lui,
258
+ * le transporte toujours.
259
+ */
260
+ function contractRegistry(service) {
261
+ const files = [];
262
+ const seen = /* @__PURE__ */ new Set();
263
+ const visit = (file) => {
264
+ if (seen.has(file)) return;
265
+ seen.add(file);
266
+ files.push(file);
267
+ for (const dependency of file.dependencies) visit(dependency);
268
+ };
269
+ visit(service.file);
270
+ return createRegistry(...files);
271
+ }
272
+ /**
273
+ * Inscrit chaque méthode du contrat. `shared` non nul : ses méthodes, liées
274
+ * une fois. `null` : une enveloppe par méthode, qui relit la cible dans la
275
+ * fiche d'appel. Un garde par genre de méthode donne à chaque enveloppe le
276
+ * type exact de sa méthode — aucune conversion.
277
+ */
278
+ function registerMethods(router, service, shared) {
279
+ for (const method of service.methods) if (isUnary(method)) {
280
+ if (shared !== null) {
281
+ const impl = pick(method, shared);
282
+ router.rpc(method, (request, ctx) => settleUnary(invoke(impl, shared, request, ctx), ctx));
283
+ } else router.rpc(method, (request, ctx) => {
284
+ const target = callTarget(ctx);
285
+ return settleUnary(invoke(pick(method, target), target, request, ctx), ctx);
286
+ });
287
+ } else if (isServerStreaming(method)) {
288
+ if (shared !== null) {
289
+ const impl = pick(method, shared);
290
+ router.rpc(method, (request, ctx) => translateFirst(invoke(impl, shared, request, ctx), ctx));
291
+ } else router.rpc(method, (request, ctx) => {
292
+ const target = callTarget(ctx);
293
+ return translateFirst(invoke(pick(method, target), target, request, ctx), ctx);
294
+ });
295
+ } else if (isClientStreaming(method)) {
296
+ if (shared !== null) {
297
+ const impl = pick(method, shared);
298
+ router.rpc(method, (requests, ctx) => translateSettled(invoke(impl, shared, requests, ctx), ctx));
299
+ } else router.rpc(method, (requests, ctx) => {
300
+ const target = callTarget(ctx);
301
+ return translateSettled(invoke(pick(method, target), target, requests, ctx), ctx);
302
+ });
303
+ } else if (isBiDiStreaming(method)) {
304
+ if (shared !== null) {
305
+ const impl = pick(method, shared);
306
+ router.rpc(method, (requests, ctx) => translateFirst(invoke(impl, shared, requests, ctx), ctx));
307
+ } else router.rpc(method, (requests, ctx) => {
308
+ const target = callTarget(ctx);
309
+ return translateFirst(invoke(pick(method, target), target, requests, ctx), ctx);
310
+ });
311
+ }
312
+ }
313
+ /**
314
+ * Lit les erreurs comme une route HTTP les lirait : statut et message client
315
+ * (`DefaultErrorRenderer.describe`). Sans état, partagé par tous les appels.
316
+ */
317
+ const ERRORS = new DefaultErrorRenderer();
318
+ /**
319
+ * Traduit une erreur levée par l'application en `ConnectError` (ADR-0026 D7) :
320
+ * une `HttpError` d'un service partagé avec des routes HTTP, une validation, un
321
+ * conflit, un code numérique, gardent leur sens — 404 → 5 `NOT_FOUND`, et non
322
+ * 13. Le message suit la règle des routes HTTP (opaque pour un 5xx en
323
+ * production) ; l'erreur d'origine reste la cause, côté serveur.
324
+ *
325
+ * Une exception que rien ne classe devient 13 avec la MÊME règle de message
326
+ * qu'une route HTTP : son message en développement (« sinon on débogue à
327
+ * l'aveugle »), opaque en production, où sa cause ne quitte jamais le serveur.
328
+ *
329
+ * Passent TELLES QUELLES : une `ConnectError` (son code est voulu) et une
330
+ * valeur qui n'est pas une `Error` (l'adaptateur en fait 13 « internal error »).
331
+ *
332
+ * Chemin d'erreur seulement : rien n'est payé sur un appel qui réussit.
333
+ */
334
+ function translateError(error, ctx) {
335
+ if (!(error instanceof Error) || error instanceof ConnectError) return error;
336
+ const { status, message } = ERRORS.describe(error);
337
+ const call = ctx.values;
338
+ const identified = call instanceof GrpcCall && call.context !== null && call.context.authenticatedUser() !== null;
339
+ return new ConnectError(message, CONNECT_CODES[applicationErrorCode(status, identified)] ?? Code.Unknown, void 0, void 0, error);
340
+ }
341
+ /**
342
+ * Le `Code` de Connect de chaque code gRPC (index = numéro, 1 à 16). La table
343
+ * D7 vit hors du sous-chemin gRPC, sans Connect (ADR-0026 D12) : elle rend
344
+ * un nombre, que cette correspondance ramène au type attendu.
345
+ */
346
+ const CONNECT_CODES = [
347
+ void 0,
348
+ Code.Canceled,
349
+ Code.Unknown,
350
+ Code.InvalidArgument,
351
+ Code.DeadlineExceeded,
352
+ Code.NotFound,
353
+ Code.AlreadyExists,
354
+ Code.PermissionDenied,
355
+ Code.ResourceExhausted,
356
+ Code.FailedPrecondition,
357
+ Code.Aborted,
358
+ Code.OutOfRange,
359
+ Code.Unimplemented,
360
+ Code.Internal,
361
+ Code.Unavailable,
362
+ Code.DataLoss,
363
+ Code.Unauthenticated
364
+ ];
365
+ /**
366
+ * Ajoute à `Access-Control-Expose-Headers` les en-têtes qu'un client RPC doit
367
+ * lire (statut, message, détails, encodage) — à ce que la politique de
368
+ * l'application expose déjà. Une chaîne composée au chargement ; un appel
369
+ * d'une origine autorisée seulement.
370
+ */
371
+ function exposeRpcHeaders(response) {
372
+ const current = response.getHeader("access-control-expose-headers");
373
+ const listed = Array.isArray(current) ? current.join(", ") : current;
374
+ response.setHeader("access-control-expose-headers", listed === void 0 || listed === "" ? RPC_EXPOSED_HEADERS : `${String(listed)}, ${RPC_EXPOSED_HEADERS}`);
375
+ }
376
+ /**
377
+ * Option `requestGate` de l'adaptateur, en simple OBSERVATEUR : Connect
378
+ * l'appelle avec le contexte d'appel AVANT de lire le corps, dans les trois
379
+ * protocoles. La fiche le garde jusqu'au `close`, où son signal porte la cause
380
+ * de la fin (ADR-0026 D10 point 2) — y compris d'un refus de Connect avant
381
+ * le gestionnaire (bombe gzip → 8, encodage inconnu → 12), que le
382
+ * gestionnaire ne voit jamais. Ne lève jamais.
383
+ */
384
+ function observeCall(ctx) {
385
+ const call = ctx.values;
386
+ if (call instanceof GrpcCall) call.open(ctx);
387
+ }
388
+ /**
389
+ * Appelle l'implémentation sur sa cible ; une erreur levée de façon SYNCHRONE
390
+ * (validation avant toute attente) est traduite ({@link translateError}). Un
391
+ * `try` ne coûte rien à l'appel qui réussit.
392
+ */
393
+ function invoke(impl, target, arg, ctx) {
394
+ try {
395
+ return impl.call(target, arg, ctx);
396
+ } catch (error) {
397
+ throw translateError(error, ctx);
398
+ }
399
+ }
400
+ /**
401
+ * Flux client (une seule réponse) : toute erreur est traduite (ADR-0026 D7,
402
+ * point 3), et l'appel se termine dès que le serveur l'interrompt (identité
403
+ * révoquée, D6) — le même règlement qu'un appel unaire. Une promesse par
404
+ * appel, aucune par message reçu.
405
+ */
406
+ function translateSettled(result, ctx) {
407
+ return settlePending(result, ctx);
408
+ }
409
+ /**
410
+ * Flux serveur et bidirectionnel : traduit les erreurs levées AVANT le premier
411
+ * message (validation, entité introuvable, droit refusé) — là où un service
412
+ * partagé avec des routes HTTP lève (ADR-0026 D7, point 3). Après le premier
413
+ * message, l'erreur passe telle quelle : 13, sauf une `ConnectError`, dont le
414
+ * code est voulu.
415
+ */
416
+ function translateFirst(source, ctx) {
417
+ return new FirstMessageTranslator(source, ctx);
418
+ }
419
+ /**
420
+ * Itérateur d'un flux : l'erreur levée AVANT le premier message est traduite
421
+ * (D7), et une source qui finit après une interruption du serveur (identité
422
+ * révoquée, arrêt) part avec le statut de l'interruption — jamais `OK`.
423
+ * `return` et `throw` vont à la source : un gestionnaire `async function*`
424
+ * voit son `finally` s'exécuter quand le client part.
425
+ *
426
+ * Coût : un `.then` par message, sur des fonctions créées une fois par appel
427
+ * (mesuré : +34 ns par message, médiane de 9) — le prix d'un flux tronqué qui
428
+ * ne se fait pas passer pour complet.
429
+ */
430
+ var FirstMessageTranslator = class {
431
+ #source;
432
+ #ctx;
433
+ /** La fiche de l'appel : son interruption arrête le flux (ADR-0026 D6). */
434
+ #call;
435
+ #started = false;
436
+ /**
437
+ * Une source qui ÉCOUTE son signal finit normalement après l'interruption :
438
+ * sa fin porte alors le statut de l'interruption, sinon Connect écrirait
439
+ * `OK` et le client prendrait un flux tronqué pour complet.
440
+ */
441
+ #onResult = (result) => {
442
+ if (result.done === true) {
443
+ const interruption = this.#call?.interruption ?? null;
444
+ if (interruption !== null) throw interruption;
445
+ }
446
+ return result;
447
+ };
448
+ /** Erreur avant le premier message : traduite au format du protocole (D7). */
449
+ #onFirstError = (error) => {
450
+ throw translateError(error, this.#ctx);
451
+ };
452
+ constructor(source, ctx) {
453
+ this.#source = source[Symbol.asyncIterator]();
454
+ this.#ctx = ctx;
455
+ const call = ctx.values;
456
+ this.#call = call instanceof GrpcCall ? call : null;
457
+ this.#call?.follow(ctx);
458
+ }
459
+ [Symbol.asyncIterator]() {
460
+ return this;
461
+ }
462
+ next() {
463
+ const interruption = this.#call?.interruption ?? null;
464
+ if (interruption !== null) return this.#stop(interruption);
465
+ if (this.#started) return this.#source.next().then(this.#onResult);
466
+ this.#started = true;
467
+ return this.#source.next().then(this.#onResult, this.#onFirstError);
468
+ }
469
+ return(value) {
470
+ const source = this.#source;
471
+ return source.return === void 0 ? Promise.resolve({
472
+ done: true,
473
+ value: void 0
474
+ }) : source.return(value);
475
+ }
476
+ throw(error) {
477
+ const source = this.#source;
478
+ if (source.throw !== void 0) return source.throw(error);
479
+ return this.return().then(() => {
480
+ throw error;
481
+ });
482
+ }
483
+ /**
484
+ * Arrête un flux interrompu : la source est refermée (le `finally` d'un
485
+ * gestionnaire `async function*` s'exécute), sans attendre qu'elle finisse
486
+ * — le statut part tout de suite.
487
+ */
488
+ #stop(reason) {
489
+ this.#source.return?.().catch(ignoreClosingError);
490
+ return Promise.reject(reason);
491
+ }
492
+ };
493
+ /** Une source qui lève en se refermant : l'appel est déjà terminé. */
494
+ function ignoreClosingError() {}
495
+ function isUnary(method) {
496
+ return method.methodKind === "unary";
497
+ }
498
+ function isServerStreaming(method) {
499
+ return method.methodKind === "server_streaming";
500
+ }
501
+ function isClientStreaming(method) {
502
+ return method.methodKind === "client_streaming";
503
+ }
504
+ function isBiDiStreaming(method) {
505
+ return method.methodKind === "bidi_streaming";
506
+ }
507
+ /** La cible de l'appel, posée par {@link GrpcEndpoint.serve} dans la fiche. */
508
+ function callTarget(ctx) {
509
+ const call = ctx.values;
510
+ if (call instanceof GrpcCall && call.target !== null) return call.target;
511
+ throw new ConnectError("appel gRPC reçu hors de GrpcEndpoint.serve : aucune cible", Code.Internal);
512
+ }
513
+ /**
514
+ * L'implémentation d'une méthode du contrat sur sa cible. La signature est
515
+ * garantie à la compilation par `@GrpcService` et la présence vérifiée au
516
+ * démarrage ; ici, le constat qu'il s'agit bien d'une fonction.
517
+ *
518
+ * @throws ConnectError `UNIMPLEMENTED` quand la cible ne la porte pas.
519
+ */
520
+ function pick(method, target) {
521
+ const impl = Reflect.get(target, method.localName);
522
+ if (isImplOf(method, impl)) return impl;
523
+ throw new ConnectError(`${method.parent.typeName}.${method.name} n'est pas implémentée`, Code.Unimplemented);
524
+ }
525
+ function isImplOf(_method, value) {
526
+ return typeof value === "function";
527
+ }
528
+ export { GrpcEndpoint };
@@ -0,0 +1,4 @@
1
+ import { GrpcCall, hasAbort } from "./GrpcCall.js";
2
+ import { GrpcEndpoint } from "./GrpcEndpoint.js";
3
+ import { markGrpcRoute } from "../src/grpc/refusal.js";
4
+ export { GrpcCall, GrpcEndpoint, hasAbort, markGrpcRoute };
@@ -0,0 +1 @@
1
+ export {};
@@ -13,7 +13,8 @@ import { aggregateProfiles } from "../src/profiler/aggregate.js";
13
13
  * `?connection=<requestId>` : les seules frames de cette socket WebSocket
14
14
  * - `GET /nodefony/profiler/api/summary` → les profils de l'anneau agrégés par
15
15
  * route (percentiles, phases, formes SQL, N+1, erreurs — `aggregateProfiles`)
16
- * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
16
+ * - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId ;
17
+ * absent → 404 `{ startedAt, clearedAt }` : depuis quand l'anneau sait quelque chose
17
18
  * - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
18
19
  *
19
20
  * La debug bar (toute page, dev) lit `X-Request-Id` de SON appel AJAX puis
@@ -68,7 +69,9 @@ function createProfilerAdminApi(profiler) {
68
69
  status: 404,
69
70
  body: {
70
71
  error: "Profile not found",
71
- requestId: params.id
72
+ requestId: params.id,
73
+ startedAt: profiler.startedAt,
74
+ clearedAt: profiler.clearedAt
72
75
  }
73
76
  };
74
77
  return entry;
@@ -1,4 +1,5 @@
1
1
  import { handshakeRefusalStatus } from "../src/context/websocket/WebsocketContext.js";
2
+ import { rpcOutcomeOf } from "../src/context/rpcOutcome.js";
2
3
  // nodefony/service/audit-logger.ts
3
4
  const statusOf = (context) => {
4
5
  return context.response?.statusCode ?? null;
@@ -18,6 +19,46 @@ function severityFromStatus(status) {
18
19
  return "INFO";
19
20
  }
20
21
  /**
22
+ * Codes gRPC qu'un SERVEUR compte comme des erreurs, selon la convention
23
+ * OpenTelemetry (`docs/rpc/grpc.md`, « SHOULD be considered errors ») :
24
+ * 2 UNKNOWN, 4 DEADLINE_EXCEEDED, 12 UNIMPLEMENTED, 13 INTERNAL,
25
+ * 14 UNAVAILABLE, 15 DATA_LOSS. Un masque : un décalage, aucune table.
26
+ */
27
+ const RPC_SERVER_ERRORS = 61460;
28
+ /**
29
+ * Gravité d'un appel RPC selon son code (ADR-0026 D10 point 4) : 0 → INFO ;
30
+ * une erreur du serveur ({@link RPC_SERVER_ERRORS}) → ERROR ; les autres —
31
+ * refus, échecs métier ou client — → WARNING.
32
+ *
33
+ * @param code - le code de l'appel (gRPC : 0 à 16).
34
+ * @returns la gravité de la ligne.
35
+ */
36
+ function severityFromRpcCode(code) {
37
+ if (code === 0) return "INFO";
38
+ return code < 32 && (RPC_SERVER_ERRORS >>> code & 1) === 1 ? "ERROR" : "WARNING";
39
+ }
40
+ /**
41
+ * Gravité de la ligne d'une requête HTTP : celle du code de son appel RPC
42
+ * quand il est fixé — le statut HTTP d'un appel gRPC reste 200 quand l'appel
43
+ * échoue —, sinon celle de son statut.
44
+ *
45
+ * @param context - le contexte de la requête.
46
+ * @param status - son statut HTTP.
47
+ * @returns la gravité de la ligne.
48
+ */
49
+ function severityOfHttp(context, status) {
50
+ const code = context.rpc?.code ?? null;
51
+ return code === null ? severityFromStatus(status) : severityFromRpcCode(code);
52
+ }
53
+ /**
54
+ * Un appel RPC en échec (code fixé, non nul) — sous un statut HTTP qui peut
55
+ * rester 200 : jamais écarté par l'échantillonnage.
56
+ */
57
+ function rpcFailed(context) {
58
+ const code = context.rpc?.code ?? null;
59
+ return code !== null && code !== 0;
60
+ }
61
+ /**
21
62
  * JSON audit logger — implements IRequestLogger so it slots into
22
63
  * `httpKernel.setRequestLogger(new JsonAuditLogger())`.
23
64
  *
@@ -43,8 +84,9 @@ var JsonAuditLogger = class {
43
84
  /**
44
85
  * Decide whether the current HTTP request must be logged (audit sampling).
45
86
  *
46
- * Always `true` when `sampleRate <= 1`, on errors, and for `status >= 400`
47
- * (failures are never sampled out). Otherwise selects 1 in `sampleRate` of
87
+ * Always `true` when `sampleRate <= 1`, on errors, for `status >= 400` and
88
+ * for a failed RPC call — whose HTTP status may stay 200 (failures are
89
+ * never sampled out). Otherwise selects 1 in `sampleRate` of
48
90
  * the 2xx/3xx requests with a deterministic counter.
49
91
  *
50
92
  * Called by `Context.logRequest()` **before** `renderHttp`, so a sampled-out
@@ -58,11 +100,13 @@ var JsonAuditLogger = class {
58
100
  const ctx = context;
59
101
  if (!this.nominalEnabled) {
60
102
  if (error ?? ctx.error) return true;
103
+ if (rpcFailed(ctx)) return true;
61
104
  const status = statusOf(ctx);
62
105
  return status !== null && status >= 400;
63
106
  }
64
107
  if (this.sampleRate <= 1) return true;
65
108
  if (error ?? ctx.error) return true;
109
+ if (rpcFailed(ctx)) return true;
66
110
  const status = statusOf(ctx);
67
111
  if (status !== null && status >= 400) return true;
68
112
  this.sampleCounter = (this.sampleCounter + 1) % this.sampleRate;
@@ -92,11 +136,12 @@ var JsonAuditLogger = class {
92
136
  name: p.name,
93
137
  durationMs: p.durationMs ?? null
94
138
  })) : void 0,
95
- error: err ? this.serializeError(err, 0) : void 0
139
+ error: err ? this.serializeError(err, 0) : void 0,
140
+ rpc: rpcOutcomeOf(ctx.rpc ?? null)
96
141
  };
97
142
  return {
98
143
  text: JSON.stringify(entry),
99
- severity: severityFromStatus(status ?? (err ? 500 : null)),
144
+ severity: severityOfHttp(ctx, status ?? (err ? 500 : null)),
100
145
  msgid: "audit"
101
146
  };
102
147
  }
@@ -125,7 +170,8 @@ var JsonAuditLogger = class {
125
170
  durationMs: p.durationMs ?? null
126
171
  })) : void 0,
127
172
  protocol: acceptedProtocol ?? null,
128
- error: error ? this.serializeError(error, 0) : void 0
173
+ error: error ? this.serializeError(error, 0) : void 0,
174
+ rpc: null
129
175
  };
130
176
  return {
131
177
  text: JSON.stringify(entry),
@@ -151,4 +197,4 @@ var JsonAuditLogger = class {
151
197
  return entry;
152
198
  }
153
199
  };
154
- export { JsonAuditLogger as default, severityFromStatus, statusOf };
200
+ export { JsonAuditLogger as default, severityFromRpcCode, severityFromStatus, severityOfHttp, statusOf };