@webda/grpc 4.0.0-beta.6 → 4.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.
@@ -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";
@@ -37,6 +41,22 @@ export class GrpcServiceParameters extends OperationsTransportParameters {
37
41
  */
38
42
  packageName?: string;
39
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;
59
+
40
60
  /**
41
61
  * Load and apply default parameter values.
42
62
  * @param params - raw configuration object to load into this parameters instance
@@ -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,34 @@ 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
+ if (!existsSync(this.parameters.protoFile)) {
160
+ useLog("WARN", `Proto file not found: ${this.parameters.protoFile}. Run 'webdac build' to generate it.`);
161
+ return;
162
+ }
163
+ try {
164
+ this.definitions = protoLoader.loadSync(this.parameters.protoFile, {
165
+ keepCase: true,
166
+ longs: String,
167
+ enums: String,
168
+ defaults: true,
169
+ oneofs: true
170
+ });
171
+ this.rpcToOperation.clear();
172
+ this.rpcMethods.clear();
173
+ this.buildRpcMap();
174
+ useLog(
175
+ "INFO",
176
+ `Loaded gRPC definitions from ${this.parameters.protoFile} — ${this.rpcToOperation.size} RPC methods mapped`
177
+ );
178
+ } catch (err: any) {
179
+ useLog("WARN", `Failed to load proto file: ${err.message}`);
180
+ }
181
+ }
182
+
127
183
  /**
128
184
  * Initialize the gRPC transport: load proto, build RPC map, hook into HTTP/2.
129
185
  * @returns a promise resolving to this service instance once initialization is complete
@@ -131,30 +187,7 @@ export class GrpcService<
131
187
  async init(): Promise<this> {
132
188
  await super.init();
133
189
 
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
- }
190
+ this.loadDefinitions();
158
191
 
159
192
  // Plug the gRPC dispatcher into every HttpServer instance in the app.
160
193
  // Serving both REST (HTTP/1.1 or TLS+ALPN) and plaintext gRPC (h2c) means
@@ -226,7 +259,21 @@ export class GrpcService<
226
259
  }
227
260
 
228
261
  /**
229
- * Handle an incoming gRPC request.
262
+ * The request line and metadata of a gRPC call as an HttpContext (HTTP/2 pseudo-headers removed)
263
+ * @param req - the HTTP/2 request
264
+ * @returns the http context
265
+ */
266
+ private httpContextOf(req: IncomingMessage): HttpContext {
267
+ const raw = req.headers as Record<string, string | string[]>;
268
+ const authority = (raw[":authority"] as string) || (raw.host as string) || "localhost";
269
+ const [hostname, port] = authority.split(":");
270
+ const headers = Object.fromEntries(Object.entries(raw).filter(([key]) => !key.startsWith(":")));
271
+ return new HttpContext(hostname, "POST", req.url || "/", "http", port || "80", headers as any);
272
+ }
273
+
274
+ /**
275
+ * Handle an incoming gRPC request: one path for the four modes. Incoming messages feed the operation (as its
276
+ * input, or as the stream in `operationInputStream`), streamed output is sent as it is produced.
230
277
  *
231
278
  * Called by the HttpServer when content-type is application/grpc.
232
279
  *
@@ -250,179 +297,101 @@ export class GrpcService<
250
297
  }
251
298
 
252
299
  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";
300
+ const streaming = getOperationStreaming(this.getOperations()[opId]);
301
+ const ctx = new GrpcOperationContext(this.httpContextOf(req), stream, res);
302
+ const input = new AsyncQueue<unknown>();
303
+ // Handlers first: data may arrive while the session loads
304
+ stream.onMessage((message, size) => {
305
+ input.push(this.cleanMessage(message), size);
306
+ const bytes = input.pendingBytes > this.parameters.maxQueuedBytes;
307
+ if ((bytes || input.pending > this.parameters.maxQueuedMessages) && !finished()) {
308
+ // The operation does not keep up: stop it rather than buffering without limit
309
+ stream.sendError(GrpcStatus.RESOURCE_EXHAUSTED, bytes ? "Too many queued bytes" : "Too many queued messages");
310
+ ctx.cancel();
311
+ input.end(true);
312
+ }
313
+ });
314
+ stream.onEnd(() => input.end());
315
+ stream.onCancel(() => {
316
+ ctx.cancel();
317
+ input.end();
318
+ });
319
+ // Whatever ends the response (client gone, or the stream failing on a bad frame): stop the operation
320
+ res.on("close", () => {
321
+ ctx.cancel();
322
+ input.end();
323
+ });
324
+ // The stream may fail itself (bad frame, ...): it then ends the response, nothing more must be written
325
+ const finished = () => ctx.isCancelled || (res as any).writableEnded;
256
326
 
257
327
  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);
328
+ await ctx.init();
329
+ const allowed = await runWithContext(ctx, async () => {
330
+ try {
331
+ emitCoreEvent("Webda.Request", { context: ctx });
332
+ } catch {
333
+ // listener error
334
+ }
335
+ return useRouter().checkRequest(ctx);
336
+ });
337
+ if (!allowed) {
338
+ if (!finished()) stream.sendError(GrpcStatus.PERMISSION_DENIED, "Forbidden");
339
+ return;
340
+ }
341
+ if (streaming === "client" || streaming === "bidi") {
342
+ ctx.setExtension("operationInputStream", input);
343
+ } else {
344
+ const first = await input.next();
345
+ if (finished()) return;
346
+ ctx.setMessage(first.done ? {} : first.value);
347
+ }
348
+ if (finished()) return;
349
+ await callOperation(ctx, opId);
350
+ if (finished()) return;
351
+ if (ctx.getExtension("operationStreaming")) {
352
+ stream.end(GrpcStatus.OK);
264
353
  } else {
265
- // Unary — single request, single response
266
- await this.handleUnary(stream, opId, methodDef);
354
+ stream.sendUnary(this.unaryResponse(ctx.getOutput()));
267
355
  }
268
- } catch (err) {
269
- const status = this.errorToGrpcStatus(err);
270
- stream.sendError(status, err.message || "Internal error");
356
+ } catch (err: any) {
357
+ if (finished()) return;
358
+ if (err instanceof WebdaError.OperationCancelledError) {
359
+ // Raised by the operation itself while the client is still there
360
+ stream.sendError(GrpcStatus.CANCELLED, err.message || "Operation cancelled");
361
+ return;
362
+ }
363
+ if (!(err?.getResponseCode?.() < 500)) useLog("ERROR", `[gRPC ${opId}] handler threw:`, err);
364
+ // Only client errors (4xx) carry their message: anything else may leak internals
365
+ const code = err?.getResponseCode?.();
366
+ const visible = typeof code === "number" && code >= 400 && code < 500;
367
+ stream.sendError(this.errorToGrpcStatus(err), visible ? err.message || "Bad request" : "Internal server error");
271
368
  }
272
369
  }
273
370
 
274
371
  /**
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
372
+ * Proto3 `optional` fields deserialize with an extra `_field: "field"` marker (synthetic oneof tracking): drop
373
+ * those so the payload matches the backing JSON schema
374
+ * @param message - decoded message
375
+ * @returns the message without markers
340
376
  */
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
- });
377
+ private cleanMessage(message: unknown): unknown {
378
+ return typeof message === "object" && message !== null && !Array.isArray(message)
379
+ ? Object.fromEntries(Object.entries(message).filter(([key]) => !key.startsWith("_")))
380
+ : message;
376
381
  }
377
382
 
378
383
  /**
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
384
+ * The unary response for an operation output (a plain string/number is wrapped as `{ value }`)
385
+ * @param output - the context output
386
+ * @returns the response message
384
387
  */
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
- });
388
+ private unaryResponse(output: string | undefined): Record<string, unknown> {
389
+ if (output === undefined || output === null || output === "") return {};
390
+ try {
391
+ return toGrpcMessage(JSON.parse(output));
392
+ } catch {
393
+ return { value: output };
394
+ }
426
395
  }
427
396
 
428
397
  /**