@webda/grpc 4.0.0-beta.5 → 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.
@@ -2,7 +2,7 @@ import { suite, test } from "@webda/test";
2
2
  import * as assert from "assert";
3
3
  import { EventEmitter } from "node:events";
4
4
  import { PassThrough } from "node:stream";
5
- import { GrpcStream, GrpcStatus, GrpcMethodDef } from "./grpc-stream.js";
5
+ import { GrpcStream, GrpcStatus, GrpcMethodDef, GRPC_MAX_MESSAGE_SIZE } from "./grpc-stream.js";
6
6
 
7
7
  /**
8
8
  * Create a mock IncomingMessage-like object (EventEmitter + readable stream)
@@ -384,6 +384,127 @@ class GrpcStreamTest {
384
384
  // Should not throw
385
385
  req.emit("close");
386
386
  }
387
+
388
+ @test
389
+ async decodesFramesSplitAnywhere() {
390
+ const req = createMockRequest();
391
+ const { res } = createMockResponse();
392
+ const stream = new GrpcStream(req as any, res, createJsonMethodDef());
393
+ const received: any[] = [];
394
+ stream.onMessage(m => received.push(m));
395
+ const bytes = Buffer.concat([
396
+ createGrpcFrame(Buffer.from(JSON.stringify({ a: 1 }))),
397
+ createGrpcFrame(Buffer.from(JSON.stringify({ b: 2 })))
398
+ ]);
399
+ // first chunk shorter than the 5-byte header, then the rest in two uneven chunks
400
+ req.emit("data", bytes.subarray(0, 2));
401
+ req.emit("data", bytes.subarray(2, 9));
402
+ req.emit("data", bytes.subarray(9));
403
+ assert.deepStrictEqual(received, [{ a: 1 }, { b: 2 }]);
404
+ }
405
+
406
+ @test
407
+ async reportsBackpressure() {
408
+ const req = createMockRequest();
409
+ const state = createMockResponse();
410
+ state.res.write = (data: Buffer) => {
411
+ state.written.push(Buffer.from(data));
412
+ return false;
413
+ };
414
+ const stream = new GrpcStream(req as any, state.res, createJsonMethodDef());
415
+ assert.strictEqual(stream.send({ x: 1 }), false);
416
+ }
417
+
418
+ @test
419
+ async closeAfterTheResponseEndedIsNotACancel() {
420
+ const req = createMockRequest();
421
+ const state = createMockResponse();
422
+ let cancelled = 0;
423
+ const stream = new GrpcStream(req as any, state.res, createJsonMethodDef());
424
+ stream.onCancel(() => cancelled++);
425
+ stream.end(0);
426
+ state.res.writableEnded = true;
427
+ req.emit("close");
428
+ assert.strictEqual(cancelled, 0);
429
+ }
430
+
431
+ @test
432
+ async closeBeforeTheResponseEndedIsACancel() {
433
+ const req = createMockRequest();
434
+ const state = createMockResponse();
435
+ let cancelled = 0;
436
+ const stream = new GrpcStream(req as any, state.res, createJsonMethodDef());
437
+ stream.onCancel(() => cancelled++);
438
+ req.emit("close");
439
+ assert.strictEqual(cancelled, 1);
440
+ }
441
+
442
+ @test
443
+ async sendReturnsTrueWhenWritable() {
444
+ const req = createMockRequest();
445
+ const state = createMockResponse();
446
+ state.res.write = () => true;
447
+ const stream = new GrpcStream(req as any, state.res, createJsonMethodDef());
448
+ assert.strictEqual(stream.send({ x: 1 }), true);
449
+ }
450
+
451
+ @test
452
+ async compressedFrameIsRejected() {
453
+ const req = createMockRequest();
454
+ const state = createMockResponse();
455
+ const def = createJsonMethodDef();
456
+ let decoded = 0;
457
+ def.requestDeserialize = () => {
458
+ decoded++;
459
+ return {};
460
+ };
461
+ const stream = new GrpcStream(req as any, state.res, def);
462
+ stream.onMessage(() => {});
463
+ const frame = createGrpcFrame(Buffer.from("{}"));
464
+ frame.writeUInt8(1, 0);
465
+ req.emit("data", frame);
466
+ assert.strictEqual(decoded, 0);
467
+ assert.strictEqual(state.trailers!["grpc-status"], "12");
468
+ assert.strictEqual(state.ended, true);
469
+ }
470
+
471
+ @test
472
+ async undecodableFrameIsRejected() {
473
+ const req = createMockRequest();
474
+ const state = createMockResponse();
475
+ const stream = new GrpcStream(req as any, state.res, createJsonMethodDef());
476
+ let received = 0;
477
+ stream.onMessage(() => received++);
478
+ req.emit("data", createGrpcFrame(Buffer.from("not json")));
479
+ assert.strictEqual(state.trailers!["grpc-status"], "3");
480
+ // further data is ignored
481
+ req.emit("data", createGrpcFrame(Buffer.from("{}")));
482
+ assert.strictEqual(received, 0);
483
+ }
484
+
485
+ @test
486
+ async oversizedFrameIsRejectedOnTheHeader() {
487
+ const req = createMockRequest();
488
+ const state = createMockResponse();
489
+ new GrpcStream(req as any, state.res, createJsonMethodDef());
490
+ const header = Buffer.alloc(5);
491
+ header.writeUInt32BE(GRPC_MAX_MESSAGE_SIZE + 1, 1);
492
+ req.emit("data", header);
493
+ assert.strictEqual(state.trailers!["grpc-status"], "8");
494
+ }
495
+
496
+ @test
497
+ async rejectedAsyncHandlerEndsWithInternal() {
498
+ const req = createMockRequest();
499
+ const state = createMockResponse();
500
+ const stream = new GrpcStream(req as any, state.res, createJsonMethodDef());
501
+ stream.onMessage(async () => {
502
+ throw new Error("boom");
503
+ });
504
+ req.emit("data", createGrpcFrame(Buffer.from("{}")));
505
+ await new Promise(resolve => setImmediate(resolve));
506
+ assert.strictEqual(state.trailers!["grpc-status"], "13");
507
+ }
387
508
  }
388
509
 
389
510
  @suite
@@ -1,5 +1,9 @@
1
1
  import type { Http2ServerRequest, Http2ServerResponse } from "node:http2";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { useLog } from "@webda/workout";
4
+
5
+ /** Largest accepted gRPC message (declared frame length), same as the gRPC default of 4 MiB */
6
+ export const GRPC_MAX_MESSAGE_SIZE = 4 * 1024 * 1024;
3
7
 
4
8
  /**
5
9
  * Parsed gRPC method definition with serializer/deserializer functions.
@@ -29,11 +33,11 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
29
33
  /** Sentinel value indicating successful stream setup */
30
34
  static OK = Symbol("OK");
31
35
 
32
- private onMessageHandler: (message: RequestType) => void | Promise<void>;
36
+ private onMessageHandler: (message: RequestType, size: number) => void | Promise<void>;
33
37
  private onEndHandler?: () => void;
34
38
  private onCancelHandler?: () => void;
35
39
  private chunk: Buffer | null = null;
36
- private messageLength = 0;
40
+ private failed = false;
37
41
  private request: IncomingMessage | Http2ServerRequest;
38
42
  private response: ServerResponse | Http2ServerResponse;
39
43
  private definition: GrpcMethodDef<RequestType, ResponseType>;
@@ -58,44 +62,72 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
58
62
  this.response.setHeader("Grpc-Accept-Encoding", "identity");
59
63
  this.response.setHeader("Grpc-Encoding", "identity");
60
64
 
61
- // Parse incoming gRPC frames
65
+ // Parse incoming gRPC frames (1-byte flag + 4-byte big-endian length + payload); a chunk may hold part of a
66
+ // header, several frames, or both
62
67
  req.on("data", (data: Buffer) => {
63
- if (this.chunk === null) {
64
- this.messageLength = data.readUInt32BE(1);
65
- this.chunk = data;
66
- } else {
67
- this.chunk = Buffer.concat([this.chunk, data]);
68
- }
69
- // Check if we have a complete message
70
- while (this.chunk && this.chunk.length >= 5 + this.messageLength) {
71
- const message = this.chunk.subarray(5, 5 + this.messageLength);
72
- const deserialized = this.definition.requestDeserialize(message);
73
- this.onMessageHandler?.(deserialized);
74
- // Advance to next frame
75
- this.chunk = this.chunk.subarray(5 + this.messageLength);
76
- if (this.chunk.length >= 5) {
77
- this.messageLength = this.chunk.readUInt32BE(1);
78
- } else if (this.chunk.length === 0) {
79
- this.chunk = null;
68
+ if (this.failed) return;
69
+ this.chunk = this.chunk ? Buffer.concat([this.chunk, data]) : data;
70
+ while (this.chunk.length >= 5) {
71
+ const compressed = this.chunk.readUInt8(0);
72
+ const length = this.chunk.readUInt32BE(1);
73
+ // Reject on the header alone: never buffer an oversized or compressed body
74
+ if (compressed) return this.fail(GrpcStatus.UNIMPLEMENTED, "compressed messages are not supported");
75
+ if (length > GRPC_MAX_MESSAGE_SIZE) {
76
+ return this.fail(GrpcStatus.RESOURCE_EXHAUSTED, `message exceeds ${GRPC_MAX_MESSAGE_SIZE} bytes`);
77
+ }
78
+ if (this.chunk.length < 5 + length) break;
79
+ const message = this.chunk.subarray(5, 5 + length);
80
+ this.chunk = this.chunk.subarray(5 + length);
81
+ let decoded: RequestType;
82
+ try {
83
+ decoded = this.definition.requestDeserialize(message);
84
+ } catch (err) {
85
+ return this.fail(GrpcStatus.INVALID_ARGUMENT, `cannot decode message: ${(err as Error)?.message}`);
86
+ }
87
+ try {
88
+ const result = this.onMessageHandler?.(decoded, length);
89
+ if (result && typeof (result as Promise<void>).catch === "function") {
90
+ (result as Promise<void>).catch(err => this.fail(GrpcStatus.INTERNAL, "message handler failed", err));
91
+ }
92
+ } catch (err) {
93
+ return this.fail(GrpcStatus.INTERNAL, "message handler failed", err);
80
94
  }
81
95
  }
96
+ if (this.chunk.length === 0) this.chunk = null;
82
97
  });
83
98
 
84
99
  req.on("end", () => {
85
100
  this.onEndHandler?.();
86
101
  });
87
102
 
103
+ // "close" also fires after a normal end: only a close before the response ended is a cancellation
88
104
  req.on("close", () => {
89
- this.onCancelHandler?.();
105
+ if (!(this.response as any).writableEnded) this.onCancelHandler?.();
90
106
  });
91
107
  }
92
108
 
109
+ /**
110
+ * Stop processing the stream and end it with an error status
111
+ * @param status - gRPC status code
112
+ * @param message - status message
113
+ * @param err - optional underlying error to log
114
+ */
115
+ private fail(status: number, message: string, err?: unknown): void {
116
+ if (err) useLog("ERROR", `gRPC stream: ${message}`, err);
117
+ if (this.failed) return;
118
+ this.failed = true;
119
+ this.chunk = null;
120
+ this.end(status, message);
121
+ // The response is over whatever the client does next: whoever feeds on this stream must stop
122
+ this.onCancelHandler?.();
123
+ }
124
+
93
125
  /**
94
126
  * Register handler for incoming messages
95
127
  * @param handler - callback invoked with each deserialized request message
96
128
  * @returns this instance for chaining
97
129
  */
98
- onMessage(handler: (message: RequestType) => void | Promise<void>): this {
130
+ onMessage(handler: (message: RequestType, size: number) => void | Promise<void>): this {
99
131
  this.onMessageHandler = handler;
100
132
  return this;
101
133
  }
@@ -123,15 +155,15 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
123
155
  /**
124
156
  * Send a response message with gRPC framing.
125
157
  * @param message - the response message to serialize and send
126
- * @returns void
158
+ * @returns false when the response buffer is full (wait for "drain")
127
159
  */
128
- send(message: ResponseType): void {
160
+ send(message: ResponseType): boolean {
129
161
  const responseBuffer = this.definition.responseSerialize(message);
130
162
  const frame = Buffer.alloc(5 + responseBuffer.length);
131
163
  frame.writeUInt8(0, 0); // Not compressed
132
164
  frame.writeUInt32BE(responseBuffer.length, 1);
133
165
  responseBuffer.copy(frame, 5);
134
- this.response.write(frame);
166
+ return this.response.write(frame) !== false;
135
167
  }
136
168
 
137
169
  /**
@@ -145,6 +177,12 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
145
177
  if (message) {
146
178
  trailers["grpc-message"] = encodeURIComponent(message);
147
179
  }
180
+ if (this.response.headersSent === false) {
181
+ // Trailers-Only response: nothing was sent, the status goes in the headers
182
+ for (const [name, value] of Object.entries(trailers)) this.response.setHeader(name, value);
183
+ this.response.end();
184
+ return;
185
+ }
148
186
  (this.response as any).addTrailers?.(trailers);
149
187
  this.response.end();
150
188
  }