@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.
- package/.webda/cache +4 -4
- package/CHANGELOG.md +46 -0
- package/README.md +24 -10
- package/lib/grpc-context.d.ts +55 -0
- package/lib/grpc-context.d.ts.map +1 -0
- package/lib/grpc-context.js +83 -0
- package/lib/grpc-context.js.map +1 -0
- package/lib/grpc-stream.d.ts +13 -4
- package/lib/grpc-stream.d.ts.map +1 -1
- package/lib/grpc-stream.js +67 -23
- package/lib/grpc-stream.js.map +1 -1
- package/lib/grpcservice.service.d.ts +41 -23
- package/lib/grpcservice.service.d.ts.map +1 -1
- package/lib/grpcservice.service.js +155 -169
- package/lib/grpcservice.service.js.map +1 -1
- package/lib/proto-generator.js +1 -1
- package/lib/proto-generator.js.map +1 -1
- package/package.json +8 -5
- package/src/connect.e2e.spec.ts +114 -0
- package/src/grpc-context.ts +93 -0
- package/src/grpc-live.spec.ts +377 -0
- package/src/grpc-stream.spec.ts +122 -1
- package/src/grpc-stream.ts +63 -25
- package/src/grpcservice.service.ts +165 -196
- package/src/grpcservice.spec.ts +29 -442
- package/src/proto-generator.spec.ts +17 -0
- package/src/proto-generator.ts +1 -1
- package/src/query-statements.spec.ts +117 -0
- package/test/fixture.ts +166 -0
- package/webda.module.json +10 -0
package/src/grpc-stream.spec.ts
CHANGED
|
@@ -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
|
package/src/grpc-stream.ts
CHANGED
|
@@ -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
|
|
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.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
this.chunk = this.chunk.subarray(5 +
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
158
|
+
* @returns false when the response buffer is full (wait for "drain")
|
|
127
159
|
*/
|
|
128
|
-
send(message: ResponseType):
|
|
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
|
}
|