@webda/grpc 4.0.0-beta.6 → 4.0.0-beta.8

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.
@@ -1,22 +1,26 @@
1
1
  import {
2
+ AsyncQueue,
3
+ BuildCommand,
4
+ HttpContext,
5
+ OperationDefinition,
2
6
  OperationsTransport,
3
7
  OperationsTransportParameters,
4
- OperationDefinition,
8
+ WebdaError,
5
9
  callOperation,
6
- OperationContext,
7
- SimpleOperationContext,
8
- WebContext,
10
+ emitCoreEvent,
11
+ getOperationStreaming,
12
+ runWithContext,
13
+ runWithInstanceStorage,
9
14
  useApplication,
10
15
  useCore,
11
- BuildCommand,
12
- ServiceParameters,
13
- runWithInstanceStorage,
14
- useInstanceStorage
16
+ useInstanceStorage,
17
+ useRouter
15
18
  } from "@webda/core";
16
19
  import { useLog } from "@webda/workout";
17
20
  import * as protoLoader from "@grpc/proto-loader";
18
21
  import { existsSync, writeFileSync, mkdirSync } from "node:fs";
19
22
  import { join, dirname } from "node:path";
23
+ import { GrpcOperationContext, toGrpcMessage } from "./grpc-context.js";
20
24
  import { GrpcStream, GrpcStatus } from "./grpc-stream.js";
21
25
  import { generateProto } from "./proto-generator.js";
22
26
  import type { IncomingMessage, ServerResponse } from "node:http";
@@ -29,13 +33,29 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
29
33
  * Path to the generated .proto file
30
34
  * @default ".webda/app.proto"
31
35
  */
32
- protoFile?: string;
36
+ protoFile: string;
33
37
 
34
38
  /**
35
39
  * Protobuf package name
36
40
  * @default "webda"
37
41
  */
38
- packageName?: string;
42
+ packageName: string;
43
+
44
+ /**
45
+ * Most unconsumed messages a client or bidirectional operation may have queued; above it the call fails with
46
+ * RESOURCE_EXHAUSTED
47
+ *
48
+ * @default 1000
49
+ */
50
+ maxQueuedMessages: number;
51
+
52
+ /**
53
+ * Most bytes of unconsumed messages a client or bidirectional operation may have queued; above it the call fails
54
+ * with RESOURCE_EXHAUSTED
55
+ *
56
+ * @default 16777216
57
+ */
58
+ maxQueuedBytes: number;
39
59
 
40
60
  /**
41
61
  * Load and apply default parameter values.
@@ -46,6 +66,8 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
46
66
  super.load(params);
47
67
  this.protoFile ??= ".webda/app.proto";
48
68
  this.packageName ??= "webda";
69
+ this.maxQueuedMessages ??= 1000;
70
+ this.maxQueuedBytes ??= 16 * 1024 * 1024;
49
71
  return this;
50
72
  }
51
73
  }
@@ -61,9 +83,7 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
61
83
  *
62
84
  * @WebdaModda
63
85
  */
64
- export class GrpcService<
65
- T extends GrpcServiceParameters = GrpcServiceParameters
66
- > extends OperationsTransport<T> {
86
+ export class GrpcService<T extends GrpcServiceParameters = GrpcServiceParameters> extends OperationsTransport<T> {
67
87
  /** Loaded protobuf definitions */
68
88
  private definitions: protoLoader.PackageDefinition;
69
89
  /** Map of gRPC path → operation ID */
@@ -71,6 +91,14 @@ export class GrpcService<
71
91
  /** Map of gRPC path → method definition */
72
92
  private rpcMethods: Map<string, protoLoader.MethodDefinition<any, any>> = new Map();
73
93
 
94
+ /**
95
+ * @param params - raw service parameters
96
+ * @returns the loaded parameters (with the operations include/exclude filter)
97
+ */
98
+ static createConfiguration(params: any = {}): GrpcServiceParameters {
99
+ return new GrpcServiceParameters().load(params);
100
+ }
101
+
74
102
  /**
75
103
  * Generate a .proto file from the current operation registry.
76
104
  *
@@ -124,6 +152,35 @@ export class GrpcService<
124
152
  return prefixes.size;
125
153
  }
126
154
 
155
+ /**
156
+ * Load the .proto file and map its RPC methods to operations (also callable after registering operations late)
157
+ */
158
+ loadDefinitions(): void {
159
+ const protoFile = this.parameters.protoFile;
160
+ if (!existsSync(protoFile)) {
161
+ useLog("WARN", `Proto file not found: ${this.parameters.protoFile}. Run 'webdac build' to generate it.`);
162
+ return;
163
+ }
164
+ try {
165
+ this.definitions = protoLoader.loadSync(protoFile, {
166
+ keepCase: true,
167
+ longs: String,
168
+ enums: String,
169
+ defaults: true,
170
+ oneofs: true
171
+ });
172
+ this.rpcToOperation.clear();
173
+ this.rpcMethods.clear();
174
+ this.buildRpcMap();
175
+ useLog(
176
+ "INFO",
177
+ `Loaded gRPC definitions from ${this.parameters.protoFile} — ${this.rpcToOperation.size} RPC methods mapped`
178
+ );
179
+ } catch (err: any) {
180
+ useLog("WARN", `Failed to load proto file: ${err.message}`);
181
+ }
182
+ }
183
+
127
184
  /**
128
185
  * Initialize the gRPC transport: load proto, build RPC map, hook into HTTP/2.
129
186
  * @returns a promise resolving to this service instance once initialization is complete
@@ -131,30 +188,7 @@ export class GrpcService<
131
188
  async init(): Promise<this> {
132
189
  await super.init();
133
190
 
134
- // Load proto file if it exists
135
- if (existsSync(this.parameters.protoFile)) {
136
- try {
137
- this.definitions = protoLoader.loadSync(this.parameters.protoFile, {
138
- keepCase: true,
139
- longs: String,
140
- enums: String,
141
- defaults: true,
142
- oneofs: true
143
- });
144
- this.buildRpcMap();
145
- useLog(
146
- "INFO",
147
- `Loaded gRPC definitions from ${this.parameters.protoFile} — ${this.rpcToOperation.size} RPC methods mapped`
148
- );
149
- } catch (err: any) {
150
- useLog("WARN", `Failed to load proto file: ${err.message}`);
151
- }
152
- } else {
153
- useLog(
154
- "WARN",
155
- `Proto file not found: ${this.parameters.protoFile}. Run 'webdac build' to generate it.`
156
- );
157
- }
191
+ this.loadDefinitions();
158
192
 
159
193
  // Plug the gRPC dispatcher into every HttpServer instance in the app.
160
194
  // Serving both REST (HTTP/1.1 or TLS+ALPN) and plaintext gRPC (h2c) means
@@ -226,7 +260,21 @@ export class GrpcService<
226
260
  }
227
261
 
228
262
  /**
229
- * Handle an incoming gRPC request.
263
+ * The request line and metadata of a gRPC call as an HttpContext (HTTP/2 pseudo-headers removed)
264
+ * @param req - the HTTP/2 request
265
+ * @returns the http context
266
+ */
267
+ private httpContextOf(req: IncomingMessage): HttpContext {
268
+ const raw = req.headers as Record<string, string | string[]>;
269
+ const authority = (raw[":authority"] as string) || (raw.host as string) || "localhost";
270
+ const [hostname, port] = authority.split(":");
271
+ const headers = Object.fromEntries(Object.entries(raw).filter(([key]) => !key.startsWith(":")));
272
+ return new HttpContext(hostname, "POST", req.url || "/", "http", port || "80", headers as any);
273
+ }
274
+
275
+ /**
276
+ * Handle an incoming gRPC request: one path for the four modes. Incoming messages feed the operation (as its
277
+ * input, or as the stream in `operationInputStream`), streamed output is sent as it is produced.
230
278
  *
231
279
  * Called by the HttpServer when content-type is application/grpc.
232
280
  *
@@ -235,7 +283,7 @@ export class GrpcService<
235
283
  * @returns a promise that resolves when the response has been fully sent
236
284
  */
237
285
  async handleGrpcRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
238
- const grpcPath = req.url;
286
+ const grpcPath = req.url ?? "";
239
287
  const opId = this.rpcToOperation.get(grpcPath);
240
288
  const methodDef = this.rpcMethods.get(grpcPath);
241
289
 
@@ -250,179 +298,101 @@ export class GrpcService<
250
298
  }
251
299
 
252
300
  const stream = new GrpcStream(req, res, methodDef);
253
- const operation = this.getOperations()[opId];
254
- const grpc = operation?.grpc;
255
- const streaming = (typeof grpc === "object" && grpc ? grpc.streaming : undefined) || "none";
301
+ const streaming = getOperationStreaming(this.getOperations()[opId]);
302
+ const ctx = new GrpcOperationContext(this.httpContextOf(req), stream, res);
303
+ const input = new AsyncQueue<unknown>();
304
+ // Handlers first: data may arrive while the session loads
305
+ stream.onMessage((message, size) => {
306
+ input.push(this.cleanMessage(message), size);
307
+ const bytes = input.pendingBytes > this.parameters.maxQueuedBytes;
308
+ if ((bytes || input.pending > this.parameters.maxQueuedMessages) && !finished()) {
309
+ // The operation does not keep up: stop it rather than buffering without limit
310
+ stream.sendError(GrpcStatus.RESOURCE_EXHAUSTED, bytes ? "Too many queued bytes" : "Too many queued messages");
311
+ ctx.cancel();
312
+ input.end(true);
313
+ }
314
+ });
315
+ stream.onEnd(() => input.end());
316
+ stream.onCancel(() => {
317
+ ctx.cancel();
318
+ input.end();
319
+ });
320
+ // Whatever ends the response (client gone, or the stream failing on a bad frame): stop the operation
321
+ res.on("close", () => {
322
+ ctx.cancel();
323
+ input.end();
324
+ });
325
+ // The stream may fail itself (bad frame, ...): it then ends the response, nothing more must be written
326
+ const finished = () => ctx.isCancelled || (res as any).writableEnded;
256
327
 
257
328
  try {
258
- if (streaming === "bidi" || streaming === "client") {
259
- // Client/bidi streaming — collect messages and process
260
- await this.handleStreamingRequest(stream, opId, streaming);
261
- } else if (streaming === "server") {
262
- // Server streaming — single request, stream responses
263
- await this.handleServerStreaming(stream, opId, methodDef);
329
+ await ctx.init();
330
+ const allowed = await runWithContext(ctx, async () => {
331
+ try {
332
+ emitCoreEvent("Webda.Request", { context: ctx });
333
+ } catch {
334
+ // listener error
335
+ }
336
+ return useRouter().checkRequest(ctx);
337
+ });
338
+ if (!allowed) {
339
+ if (!finished()) stream.sendError(GrpcStatus.PERMISSION_DENIED, "Forbidden");
340
+ return;
341
+ }
342
+ if (streaming === "client" || streaming === "bidi") {
343
+ ctx.setExtension("operationInputStream", input);
344
+ } else {
345
+ const first = await input.next();
346
+ if (finished()) return;
347
+ ctx.setMessage(first.done ? {} : first.value);
348
+ }
349
+ if (finished()) return;
350
+ await callOperation(ctx, opId);
351
+ if (finished()) return;
352
+ if (ctx.getExtension("operationStreaming")) {
353
+ stream.end(GrpcStatus.OK);
264
354
  } else {
265
- // Unary — single request, single response
266
- await this.handleUnary(stream, opId, methodDef);
355
+ stream.sendUnary(this.unaryResponse(ctx.getOutput()));
267
356
  }
268
- } catch (err) {
269
- const status = this.errorToGrpcStatus(err);
270
- stream.sendError(status, err.message || "Internal error");
357
+ } catch (err: any) {
358
+ if (finished()) return;
359
+ if (err instanceof WebdaError.OperationCancelledError) {
360
+ // Raised by the operation itself while the client is still there
361
+ stream.sendError(GrpcStatus.CANCELLED, err.message || "Operation cancelled");
362
+ return;
363
+ }
364
+ if (!(err?.getResponseCode?.() < 500)) useLog("ERROR", `[gRPC ${opId}] handler threw:`, err);
365
+ // Only client errors (4xx) carry their message: anything else may leak internals
366
+ const code = err?.getResponseCode?.();
367
+ const visible = typeof code === "number" && code >= 400 && code < 500;
368
+ stream.sendError(this.errorToGrpcStatus(err), visible ? err.message || "Bad request" : "Internal server error");
271
369
  }
272
370
  }
273
371
 
274
372
  /**
275
- * Handle a unary gRPC call (single request → single response).
276
- * @param stream - the active GrpcStream for this request
277
- * @param opId - the Webda operation ID to invoke
278
- * @param methodDef - the protobuf method definition with serializer functions
279
- * @returns a promise that resolves when the unary response has been sent
280
- */
281
- private handleUnary(
282
- stream: GrpcStream,
283
- opId: string,
284
- methodDef: protoLoader.MethodDefinition<any, any>
285
- ): Promise<void> {
286
- // AsyncLocalStorage doesn't always propagate through the HTTP/2 'data'
287
- // event that drives GrpcStream.onMessage, so capture the storage here and
288
- // re-enter it inside the callback.
289
- const instanceStorage = useInstanceStorage();
290
- return new Promise<void>((resolve, reject) => {
291
- stream.onMessage(message => {
292
- runWithInstanceStorage(instanceStorage, async () => {
293
- try {
294
- const ctx = new SimpleOperationContext();
295
- await ctx.init();
296
- // Proto3 `optional` fields deserialize with an extra `_field: "field"`
297
- // marker (synthetic oneof tracking). Drop those so the payload matches
298
- // the backing JSON schema which forbids additional properties.
299
- const cleaned =
300
- typeof message === "object" && message !== null && !Array.isArray(message)
301
- ? Object.fromEntries(Object.entries(message).filter(([k]) => !k.startsWith("_")))
302
- : message;
303
- ctx.setInput(Buffer.from(JSON.stringify(cleaned)));
304
- await callOperation(ctx, opId);
305
- const output = ctx.getOutput();
306
- // Operation return values may be plain strings/numbers (e.g. version
307
- // strings, publish ids). Wrap them in a `{value: ...}` object so the
308
- // proto response is always a valid message shape.
309
- let response: any = {};
310
- if (output !== undefined && output !== null && output !== "") {
311
- try {
312
- response = JSON.parse(output);
313
- if (typeof response !== "object" || response === null) {
314
- response = { value: response };
315
- }
316
- } catch {
317
- response = { value: output };
318
- }
319
- }
320
- stream.sendUnary(response);
321
- resolve();
322
- } catch (err: any) {
323
- useLog("ERROR", `[gRPC ${opId}] handler threw:`, err);
324
- const status = this.errorToGrpcStatus(err);
325
- stream.sendError(status, err.message);
326
- resolve(); // Don't reject — error was sent via gRPC status
327
- }
328
- });
329
- });
330
- });
331
- }
332
-
333
- /**
334
- * Handle server streaming (single request → stream of responses).
335
- * Maps to AsyncGenerator operations.
336
- * @param stream - the active GrpcStream for this request
337
- * @param opId - the Webda operation ID to invoke
338
- * @param methodDef - the protobuf method definition with serializer functions
339
- * @returns a promise that resolves when all response frames have been sent
373
+ * Proto3 `optional` fields deserialize with an extra `_field: "field"` marker (synthetic oneof tracking): drop
374
+ * those so the payload matches the backing JSON schema
375
+ * @param message - decoded message
376
+ * @returns the message without markers
340
377
  */
341
- private handleServerStreaming(
342
- stream: GrpcStream,
343
- opId: string,
344
- methodDef: protoLoader.MethodDefinition<any, any>
345
- ): Promise<void> {
346
- return new Promise<void>((resolve, reject) => {
347
- stream.onMessage(async message => {
348
- try {
349
- const ctx = new SimpleOperationContext();
350
- await ctx.init();
351
- ctx.setInput(Buffer.from(JSON.stringify(message)));
352
- await callOperation(ctx, opId);
353
- // callOperation writes chunks to ctx for AsyncGenerators
354
- // For now, send the full output as a single response
355
- const output = ctx.getOutput();
356
- if (output) {
357
- // Try to parse as NDJSON (multiple JSON objects)
358
- const lines = output.split("\n").filter(Boolean);
359
- for (const line of lines) {
360
- try {
361
- stream.send(JSON.parse(line));
362
- } catch {
363
- stream.send({ data: line });
364
- }
365
- }
366
- }
367
- stream.end(GrpcStatus.OK);
368
- resolve();
369
- } catch (err: any) {
370
- const status = this.errorToGrpcStatus(err);
371
- stream.sendError(status, err.message);
372
- resolve();
373
- }
374
- });
375
- });
378
+ private cleanMessage(message: unknown): unknown {
379
+ return typeof message === "object" && message !== null && !Array.isArray(message)
380
+ ? Object.fromEntries(Object.entries(message).filter(([key]) => !key.startsWith("_")))
381
+ : message;
376
382
  }
377
383
 
378
384
  /**
379
- * Handle client or bidirectional streaming.
380
- * @param stream - the active GrpcStream for this request
381
- * @param opId - the Webda operation ID to invoke
382
- * @param streaming - streaming mode, either "client" or "bidi"
383
- * @returns a promise that resolves when the operation completes and the response is sent
385
+ * The unary response for an operation output (a plain string/number is wrapped as `{ value }`)
386
+ * @param output - the context output
387
+ * @returns the response message
384
388
  */
385
- private handleStreamingRequest(
386
- stream: GrpcStream,
387
- opId: string,
388
- streaming: string
389
- ): Promise<void> {
390
- return new Promise<void>((resolve) => {
391
- const messages: any[] = [];
392
-
393
- stream.onMessage(message => {
394
- messages.push(message);
395
- });
396
-
397
- stream.onEnd(async () => {
398
- try {
399
- // For client streaming: process all collected messages
400
- const ctx = new SimpleOperationContext();
401
- await ctx.init();
402
- ctx.setInput(Buffer.from(JSON.stringify(messages)));
403
- await callOperation(ctx, opId);
404
- const output = ctx.getOutput();
405
- const response = output ? JSON.parse(output) : {};
406
-
407
- if (streaming === "bidi") {
408
- // Bidi: send response for each input message
409
- stream.send(response);
410
- } else {
411
- // Client streaming: single response
412
- stream.sendUnary(response);
413
- }
414
- resolve();
415
- } catch (err: any) {
416
- const status = this.errorToGrpcStatus(err);
417
- stream.sendError(status, err.message);
418
- resolve();
419
- }
420
- });
421
-
422
- stream.onCancel(() => {
423
- resolve();
424
- });
425
- });
389
+ private unaryResponse(output: string | undefined): Record<string, unknown> {
390
+ if (output === undefined || output === null || output === "") return {};
391
+ try {
392
+ return toGrpcMessage(JSON.parse(output));
393
+ } catch {
394
+ return { value: output };
395
+ }
426
396
  }
427
397
 
428
398
  /**