@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.
@@ -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,10 @@
1
+ import type { Writable } from "node:stream";
1
2
  import type { Http2ServerRequest, Http2ServerResponse } from "node:http2";
2
3
  import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { useLog } from "@webda/workout";
5
+
6
+ /** Largest accepted gRPC message (declared frame length), same as the gRPC default of 4 MiB */
7
+ export const GRPC_MAX_MESSAGE_SIZE = 4 * 1024 * 1024;
3
8
 
4
9
  /**
5
10
  * Parsed gRPC method definition with serializer/deserializer functions.
@@ -29,11 +34,11 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
29
34
  /** Sentinel value indicating successful stream setup */
30
35
  static OK = Symbol("OK");
31
36
 
32
- private onMessageHandler: (message: RequestType) => void | Promise<void>;
37
+ private onMessageHandler: (message: RequestType, size: number) => void | Promise<void>;
33
38
  private onEndHandler?: () => void;
34
39
  private onCancelHandler?: () => void;
35
40
  private chunk: Buffer | null = null;
36
- private messageLength = 0;
41
+ private failed = false;
37
42
  private request: IncomingMessage | Http2ServerRequest;
38
43
  private response: ServerResponse | Http2ServerResponse;
39
44
  private definition: GrpcMethodDef<RequestType, ResponseType>;
@@ -58,44 +63,72 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
58
63
  this.response.setHeader("Grpc-Accept-Encoding", "identity");
59
64
  this.response.setHeader("Grpc-Encoding", "identity");
60
65
 
61
- // Parse incoming gRPC frames
66
+ // Parse incoming gRPC frames (1-byte flag + 4-byte big-endian length + payload); a chunk may hold part of a
67
+ // header, several frames, or both
62
68
  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;
69
+ if (this.failed) return;
70
+ this.chunk = this.chunk ? Buffer.concat([this.chunk, data]) : data;
71
+ while (this.chunk.length >= 5) {
72
+ const compressed = this.chunk.readUInt8(0);
73
+ const length = this.chunk.readUInt32BE(1);
74
+ // Reject on the header alone: never buffer an oversized or compressed body
75
+ if (compressed) return this.fail(GrpcStatus.UNIMPLEMENTED, "compressed messages are not supported");
76
+ if (length > GRPC_MAX_MESSAGE_SIZE) {
77
+ return this.fail(GrpcStatus.RESOURCE_EXHAUSTED, `message exceeds ${GRPC_MAX_MESSAGE_SIZE} bytes`);
78
+ }
79
+ if (this.chunk.length < 5 + length) break;
80
+ const message = this.chunk.subarray(5, 5 + length);
81
+ this.chunk = this.chunk.subarray(5 + length);
82
+ let decoded: RequestType;
83
+ try {
84
+ decoded = this.definition.requestDeserialize(message);
85
+ } catch (err) {
86
+ return this.fail(GrpcStatus.INVALID_ARGUMENT, `cannot decode message: ${(err as Error)?.message}`);
87
+ }
88
+ try {
89
+ const result = this.onMessageHandler?.(decoded, length);
90
+ if (result && typeof (result as Promise<void>).catch === "function") {
91
+ (result as Promise<void>).catch(err => this.fail(GrpcStatus.INTERNAL, "message handler failed", err));
92
+ }
93
+ } catch (err) {
94
+ return this.fail(GrpcStatus.INTERNAL, "message handler failed", err);
80
95
  }
81
96
  }
97
+ if (this.chunk.length === 0) this.chunk = null;
82
98
  });
83
99
 
84
100
  req.on("end", () => {
85
101
  this.onEndHandler?.();
86
102
  });
87
103
 
104
+ // "close" also fires after a normal end: only a close before the response ended is a cancellation
88
105
  req.on("close", () => {
89
- this.onCancelHandler?.();
106
+ if (!(this.response as any).writableEnded) this.onCancelHandler?.();
90
107
  });
91
108
  }
92
109
 
110
+ /**
111
+ * Stop processing the stream and end it with an error status
112
+ * @param status - gRPC status code
113
+ * @param message - status message
114
+ * @param err - optional underlying error to log
115
+ */
116
+ private fail(status: number, message: string, err?: unknown): void {
117
+ if (err) useLog("ERROR", `gRPC stream: ${message}`, err);
118
+ if (this.failed) return;
119
+ this.failed = true;
120
+ this.chunk = null;
121
+ this.end(status, message);
122
+ // The response is over whatever the client does next: whoever feeds on this stream must stop
123
+ this.onCancelHandler?.();
124
+ }
125
+
93
126
  /**
94
127
  * Register handler for incoming messages
95
128
  * @param handler - callback invoked with each deserialized request message
96
129
  * @returns this instance for chaining
97
130
  */
98
- onMessage(handler: (message: RequestType) => void | Promise<void>): this {
131
+ onMessage(handler: (message: RequestType, size: number) => void | Promise<void>): this {
99
132
  this.onMessageHandler = handler;
100
133
  return this;
101
134
  }
@@ -123,15 +156,15 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
123
156
  /**
124
157
  * Send a response message with gRPC framing.
125
158
  * @param message - the response message to serialize and send
126
- * @returns void
159
+ * @returns false when the response buffer is full (wait for "drain")
127
160
  */
128
- send(message: ResponseType): void {
161
+ send(message: ResponseType): boolean {
129
162
  const responseBuffer = this.definition.responseSerialize(message);
130
163
  const frame = Buffer.alloc(5 + responseBuffer.length);
131
164
  frame.writeUInt8(0, 0); // Not compressed
132
165
  frame.writeUInt32BE(responseBuffer.length, 1);
133
166
  responseBuffer.copy(frame, 5);
134
- this.response.write(frame);
167
+ return (this.response as Writable).write(frame) !== false;
135
168
  }
136
169
 
137
170
  /**
@@ -145,6 +178,12 @@ export class GrpcStream<RequestType = any, ResponseType = any> {
145
178
  if (message) {
146
179
  trailers["grpc-message"] = encodeURIComponent(message);
147
180
  }
181
+ if (this.response.headersSent === false) {
182
+ // Trailers-Only response: nothing was sent, the status goes in the headers
183
+ for (const [name, value] of Object.entries(trailers)) this.response.setHeader(name, value);
184
+ this.response.end();
185
+ return;
186
+ }
148
187
  (this.response as any).addTrailers?.(trailers);
149
188
  this.response.end();
150
189
  }