@webda/grpc 4.0.0-beta.3

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.
@@ -0,0 +1,417 @@
1
+ import { suite, test } from "@webda/test";
2
+ import * as assert from "assert";
3
+ import { EventEmitter } from "node:events";
4
+ import { PassThrough } from "node:stream";
5
+ import { GrpcStream, GrpcStatus, GrpcMethodDef } from "./grpc-stream.js";
6
+
7
+ /**
8
+ * Create a mock IncomingMessage-like object (EventEmitter + readable stream)
9
+ */
10
+ function createMockRequest(): EventEmitter & { headers: Record<string, string> } {
11
+ const req = new PassThrough() as any;
12
+ req.headers = { "content-type": "application/grpc" };
13
+ return req;
14
+ }
15
+
16
+ /**
17
+ * Create a mock ServerResponse-like object that captures writes and trailers
18
+ */
19
+ function createMockResponse(): {
20
+ res: any;
21
+ written: Buffer[];
22
+ headers: Record<string, string>;
23
+ trailers: Record<string, string> | null;
24
+ ended: boolean;
25
+ } {
26
+ const state = {
27
+ written: [] as Buffer[],
28
+ headers: {} as Record<string, string>,
29
+ trailers: null as Record<string, string> | null,
30
+ ended: false,
31
+ res: null as any
32
+ };
33
+
34
+ state.res = {
35
+ setHeader(key: string, value: string) {
36
+ state.headers[key] = value;
37
+ },
38
+ write(data: Buffer) {
39
+ state.written.push(Buffer.from(data));
40
+ },
41
+ addTrailers(t: Record<string, string>) {
42
+ state.trailers = t;
43
+ },
44
+ end() {
45
+ state.ended = true;
46
+ }
47
+ };
48
+
49
+ return state;
50
+ }
51
+
52
+ /**
53
+ * Create a simple JSON-based method definition for testing
54
+ */
55
+ function createJsonMethodDef(): GrpcMethodDef<any, any> {
56
+ return {
57
+ requestSerialize: (msg: any) => Buffer.from(JSON.stringify(msg)),
58
+ requestDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
59
+ responseSerialize: (msg: any) => Buffer.from(JSON.stringify(msg)),
60
+ responseDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
61
+ requestStream: false,
62
+ responseStream: false
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Create a gRPC frame from a message buffer
68
+ * Format: 1 byte compressed flag + 4 bytes big-endian length + payload
69
+ */
70
+ function createGrpcFrame(data: Buffer): Buffer {
71
+ const frame = Buffer.alloc(5 + data.length);
72
+ frame.writeUInt8(0, 0); // Not compressed
73
+ frame.writeUInt32BE(data.length, 1);
74
+ data.copy(frame, 5);
75
+ return frame;
76
+ }
77
+
78
+ @suite
79
+ class GrpcStreamTest {
80
+ @test
81
+ constructorSetsHeaders() {
82
+ const req = createMockRequest();
83
+ const { res, headers } = createMockResponse();
84
+ const def = createJsonMethodDef();
85
+
86
+ new GrpcStream(req as any, res, def);
87
+
88
+ assert.strictEqual(headers["Content-Type"], "application/grpc");
89
+ assert.strictEqual(headers["Grpc-Accept-Encoding"], "identity");
90
+ assert.strictEqual(headers["Grpc-Encoding"], "identity");
91
+ }
92
+
93
+ @test
94
+ async onMessageReceivesDeserializedMessage() {
95
+ const req = createMockRequest();
96
+ const { res } = createMockResponse();
97
+ const def = createJsonMethodDef();
98
+
99
+ const stream = new GrpcStream(req as any, res, def);
100
+
101
+ const received: any[] = [];
102
+ stream.onMessage(msg => {
103
+ received.push(msg);
104
+ });
105
+
106
+ const payload = Buffer.from(JSON.stringify({ hello: "world" }));
107
+ const frame = createGrpcFrame(payload);
108
+ req.emit("data", frame);
109
+
110
+ assert.strictEqual(received.length, 1);
111
+ assert.deepStrictEqual(received[0], { hello: "world" });
112
+ }
113
+
114
+ @test
115
+ async sendCreatesProperGrpcFrame() {
116
+ const req = createMockRequest();
117
+ const { res, written } = createMockResponse();
118
+ const def = createJsonMethodDef();
119
+
120
+ const stream = new GrpcStream(req as any, res, def);
121
+
122
+ stream.send({ result: "ok" });
123
+
124
+ assert.strictEqual(written.length, 1);
125
+ const frame = written[0];
126
+
127
+ // Verify frame structure: 1 byte compression + 4 bytes length + payload
128
+ assert.strictEqual(frame.readUInt8(0), 0, "Compression flag should be 0");
129
+ const payloadLength = frame.readUInt32BE(1);
130
+ const payload = frame.subarray(5, 5 + payloadLength);
131
+ const decoded = JSON.parse(payload.toString());
132
+ assert.deepStrictEqual(decoded, { result: "ok" });
133
+ }
134
+
135
+ @test
136
+ async endWritesTrailersWithGrpcStatus() {
137
+ const req = createMockRequest();
138
+ const { res, trailers, ended } = createMockResponse();
139
+ const mock = createMockResponse();
140
+ const def = createJsonMethodDef();
141
+
142
+ const stream = new GrpcStream(req as any, mock.res, def);
143
+
144
+ stream.end(0);
145
+
146
+ assert.ok(mock.ended, "Response should be ended");
147
+ assert.ok(mock.trailers !== null, "Trailers should be set");
148
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
149
+ assert.strictEqual(mock.trailers!["grpc-message"], undefined, "No message for OK status");
150
+ }
151
+
152
+ @test
153
+ async endWithMessageEncodesURIComponent() {
154
+ const req = createMockRequest();
155
+ const mock = createMockResponse();
156
+ const def = createJsonMethodDef();
157
+
158
+ const stream = new GrpcStream(req as any, mock.res, def);
159
+
160
+ stream.end(13, "Internal server error");
161
+
162
+ assert.ok(mock.trailers !== null);
163
+ assert.strictEqual(mock.trailers!["grpc-status"], "13");
164
+ assert.strictEqual(mock.trailers!["grpc-message"], encodeURIComponent("Internal server error"));
165
+ }
166
+
167
+ @test
168
+ async endDefaultStatusIsZero() {
169
+ const req = createMockRequest();
170
+ const mock = createMockResponse();
171
+ const def = createJsonMethodDef();
172
+
173
+ const stream = new GrpcStream(req as any, mock.res, def);
174
+
175
+ stream.end(); // No arguments
176
+
177
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
178
+ }
179
+
180
+ @test
181
+ async sendUnarySendsMessageThenEnds() {
182
+ const req = createMockRequest();
183
+ const mock = createMockResponse();
184
+ const def = createJsonMethodDef();
185
+
186
+ const stream = new GrpcStream(req as any, mock.res, def);
187
+
188
+ stream.sendUnary({ data: "response" });
189
+
190
+ // Should have written one frame
191
+ assert.strictEqual(mock.written.length, 1);
192
+ const payload = mock.written[0].subarray(5);
193
+ assert.deepStrictEqual(JSON.parse(payload.toString()), { data: "response" });
194
+
195
+ // Should have ended with status 0
196
+ assert.ok(mock.ended);
197
+ assert.strictEqual(mock.trailers!["grpc-status"], "0");
198
+ }
199
+
200
+ @test
201
+ async sendErrorEndsWithErrorStatus() {
202
+ const req = createMockRequest();
203
+ const mock = createMockResponse();
204
+ const def = createJsonMethodDef();
205
+
206
+ const stream = new GrpcStream(req as any, mock.res, def);
207
+
208
+ stream.sendError(GrpcStatus.NOT_FOUND, "Resource not found");
209
+
210
+ assert.ok(mock.ended);
211
+ assert.strictEqual(mock.trailers!["grpc-status"], "5");
212
+ assert.strictEqual(mock.trailers!["grpc-message"], encodeURIComponent("Resource not found"));
213
+ // Should NOT have written any data frames
214
+ assert.strictEqual(mock.written.length, 0);
215
+ }
216
+
217
+ @test
218
+ async multipleMessagesInOneChunk() {
219
+ const req = createMockRequest();
220
+ const mock = createMockResponse();
221
+ const def = createJsonMethodDef();
222
+
223
+ const stream = new GrpcStream(req as any, mock.res, def);
224
+
225
+ const received: any[] = [];
226
+ stream.onMessage(msg => {
227
+ received.push(msg);
228
+ });
229
+
230
+ const payload1 = Buffer.from(JSON.stringify({ id: 1 }));
231
+ const payload2 = Buffer.from(JSON.stringify({ id: 2 }));
232
+ const frame1 = createGrpcFrame(payload1);
233
+ const frame2 = createGrpcFrame(payload2);
234
+
235
+ // Send both frames in a single data event
236
+ const combined = Buffer.concat([frame1, frame2]);
237
+ req.emit("data", combined);
238
+
239
+ assert.strictEqual(received.length, 2);
240
+ assert.deepStrictEqual(received[0], { id: 1 });
241
+ assert.deepStrictEqual(received[1], { id: 2 });
242
+ }
243
+
244
+ @test
245
+ async splitChunksPartialMessage() {
246
+ const req = createMockRequest();
247
+ const mock = createMockResponse();
248
+ const def = createJsonMethodDef();
249
+
250
+ const stream = new GrpcStream(req as any, mock.res, def);
251
+
252
+ const received: any[] = [];
253
+ stream.onMessage(msg => {
254
+ received.push(msg);
255
+ });
256
+
257
+ const payload = Buffer.from(JSON.stringify({ key: "value" }));
258
+ const frame = createGrpcFrame(payload);
259
+
260
+ // Split the frame at an arbitrary point (midway through the payload)
261
+ const splitPoint = 8; // After header + a few bytes of payload
262
+ const part1 = frame.subarray(0, splitPoint);
263
+ const part2 = frame.subarray(splitPoint);
264
+
265
+ // Emit first part — incomplete message
266
+ req.emit("data", part1);
267
+ assert.strictEqual(received.length, 0, "Should not deliver incomplete message");
268
+
269
+ // Emit second part — completes the message
270
+ req.emit("data", part2);
271
+ assert.strictEqual(received.length, 1, "Should deliver message after all parts arrive");
272
+ assert.deepStrictEqual(received[0], { key: "value" });
273
+ }
274
+
275
+ @test
276
+ async onEndHandlerCalledWhenRequestEnds() {
277
+ const req = createMockRequest();
278
+ const mock = createMockResponse();
279
+ const def = createJsonMethodDef();
280
+
281
+ const stream = new GrpcStream(req as any, mock.res, def);
282
+
283
+ let endCalled = false;
284
+ stream.onEnd(() => {
285
+ endCalled = true;
286
+ });
287
+
288
+ req.emit("end");
289
+
290
+ assert.ok(endCalled, "onEnd handler should be called");
291
+ }
292
+
293
+ @test
294
+ async onCancelHandlerCalledWhenRequestCloses() {
295
+ const req = createMockRequest();
296
+ const mock = createMockResponse();
297
+ const def = createJsonMethodDef();
298
+
299
+ const stream = new GrpcStream(req as any, mock.res, def);
300
+
301
+ let cancelCalled = false;
302
+ stream.onCancel(() => {
303
+ cancelCalled = true;
304
+ });
305
+
306
+ req.emit("close");
307
+
308
+ assert.ok(cancelCalled, "onCancel handler should be called");
309
+ }
310
+
311
+ @test
312
+ chainingReturnsSameInstance() {
313
+ const req = createMockRequest();
314
+ const mock = createMockResponse();
315
+ const def = createJsonMethodDef();
316
+
317
+ const stream = new GrpcStream(req as any, mock.res, def);
318
+
319
+ const result = stream
320
+ .onMessage(() => {})
321
+ .onEnd(() => {})
322
+ .onCancel(() => {});
323
+
324
+ assert.strictEqual(result, stream, "Chaining methods should return the same instance");
325
+ }
326
+
327
+ @test
328
+ async endHandlesResponseWithoutAddTrailers() {
329
+ const req = createMockRequest();
330
+ const def = createJsonMethodDef();
331
+
332
+ // Create a response that does NOT have addTrailers
333
+ const res = {
334
+ setHeader() {},
335
+ write() {},
336
+ end() {}
337
+ // no addTrailers
338
+ };
339
+
340
+ const stream = new GrpcStream(req as any, res as any, def);
341
+
342
+ // Should not throw even without addTrailers
343
+ stream.end(0);
344
+ }
345
+
346
+ @test
347
+ async noMessageHandlerDoesNotThrow() {
348
+ const req = createMockRequest();
349
+ const mock = createMockResponse();
350
+ const def = createJsonMethodDef();
351
+
352
+ // Create stream without registering onMessage
353
+ new GrpcStream(req as any, mock.res, def);
354
+
355
+ const payload = Buffer.from(JSON.stringify({ test: true }));
356
+ const frame = createGrpcFrame(payload);
357
+
358
+ // Should not throw when no handler is registered
359
+ req.emit("data", frame);
360
+ }
361
+
362
+ @test
363
+ async noEndHandlerDoesNotThrow() {
364
+ const req = createMockRequest();
365
+ const mock = createMockResponse();
366
+ const def = createJsonMethodDef();
367
+
368
+ // No onEnd registered
369
+ new GrpcStream(req as any, mock.res, def);
370
+
371
+ // Should not throw
372
+ req.emit("end");
373
+ }
374
+
375
+ @test
376
+ async noCancelHandlerDoesNotThrow() {
377
+ const req = createMockRequest();
378
+ const mock = createMockResponse();
379
+ const def = createJsonMethodDef();
380
+
381
+ // No onCancel registered
382
+ new GrpcStream(req as any, mock.res, def);
383
+
384
+ // Should not throw
385
+ req.emit("close");
386
+ }
387
+ }
388
+
389
+ @suite
390
+ class GrpcStatusTest {
391
+ @test
392
+ statusCodeValues() {
393
+ assert.strictEqual(GrpcStatus.OK, 0);
394
+ assert.strictEqual(GrpcStatus.CANCELLED, 1);
395
+ assert.strictEqual(GrpcStatus.UNKNOWN, 2);
396
+ assert.strictEqual(GrpcStatus.INVALID_ARGUMENT, 3);
397
+ assert.strictEqual(GrpcStatus.DEADLINE_EXCEEDED, 4);
398
+ assert.strictEqual(GrpcStatus.NOT_FOUND, 5);
399
+ assert.strictEqual(GrpcStatus.ALREADY_EXISTS, 6);
400
+ assert.strictEqual(GrpcStatus.PERMISSION_DENIED, 7);
401
+ assert.strictEqual(GrpcStatus.RESOURCE_EXHAUSTED, 8);
402
+ assert.strictEqual(GrpcStatus.FAILED_PRECONDITION, 9);
403
+ assert.strictEqual(GrpcStatus.ABORTED, 10);
404
+ assert.strictEqual(GrpcStatus.OUT_OF_RANGE, 11);
405
+ assert.strictEqual(GrpcStatus.UNIMPLEMENTED, 12);
406
+ assert.strictEqual(GrpcStatus.INTERNAL, 13);
407
+ assert.strictEqual(GrpcStatus.UNAVAILABLE, 14);
408
+ assert.strictEqual(GrpcStatus.DATA_LOSS, 15);
409
+ assert.strictEqual(GrpcStatus.UNAUTHENTICATED, 16);
410
+ }
411
+
412
+ @test
413
+ okSymbol() {
414
+ assert.ok(typeof GrpcStream.OK === "symbol", "OK should be a symbol");
415
+ assert.strictEqual(GrpcStream.OK.toString(), "Symbol(OK)");
416
+ }
417
+ }
@@ -0,0 +1,192 @@
1
+ import type { Http2ServerRequest, Http2ServerResponse } from "node:http2";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+
4
+ /**
5
+ * Parsed gRPC method definition with serializer/deserializer functions.
6
+ */
7
+ export interface GrpcMethodDef<Req = any, Res = any> {
8
+ requestSerialize: (msg: Req) => Buffer;
9
+ requestDeserialize: (buf: Buffer) => Req;
10
+ responseSerialize: (msg: Res) => Buffer;
11
+ responseDeserialize: (buf: Buffer) => Res;
12
+ requestStream: boolean;
13
+ responseStream: boolean;
14
+ }
15
+
16
+ /**
17
+ * gRPC bidirectional stream handler.
18
+ *
19
+ * Manages frame parsing (5-byte header: compressed flag + 4-byte big-endian length),
20
+ * message deserialization, and response framing for HTTP/2 gRPC streams.
21
+ *
22
+ * Supports all four gRPC patterns:
23
+ * - Unary (single request, single response)
24
+ * - Server streaming (single request, stream of responses)
25
+ * - Client streaming (stream of requests, single response)
26
+ * - Bidirectional streaming (stream of requests, stream of responses)
27
+ */
28
+ export class GrpcStream<RequestType = any, ResponseType = any> {
29
+ /** Sentinel value indicating successful stream setup */
30
+ static OK = Symbol("OK");
31
+
32
+ private onMessageHandler: (message: RequestType) => void | Promise<void>;
33
+ private onEndHandler?: () => void;
34
+ private onCancelHandler?: () => void;
35
+ private chunk: Buffer | null = null;
36
+ private messageLength = 0;
37
+ private request: IncomingMessage | Http2ServerRequest;
38
+ private response: ServerResponse | Http2ServerResponse;
39
+ private definition: GrpcMethodDef<RequestType, ResponseType>;
40
+
41
+ /**
42
+ * Create a new GrpcStream to handle a single gRPC request/response lifecycle.
43
+ * @param req - the incoming HTTP/2 request carrying the gRPC frames
44
+ * @param res - the HTTP/2 response used to write gRPC frames and trailers
45
+ * @param definition - protobuf method definition providing serialize/deserialize functions
46
+ */
47
+ constructor(
48
+ req: IncomingMessage | Http2ServerRequest,
49
+ res: ServerResponse | Http2ServerResponse,
50
+ definition: GrpcMethodDef<RequestType, ResponseType>
51
+ ) {
52
+ this.request = req;
53
+ this.response = res;
54
+ this.definition = definition;
55
+
56
+ // Set gRPC response headers
57
+ this.response.setHeader("Content-Type", "application/grpc");
58
+ this.response.setHeader("Grpc-Accept-Encoding", "identity");
59
+ this.response.setHeader("Grpc-Encoding", "identity");
60
+
61
+ // Parse incoming gRPC frames
62
+ 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;
80
+ }
81
+ }
82
+ });
83
+
84
+ req.on("end", () => {
85
+ this.onEndHandler?.();
86
+ });
87
+
88
+ req.on("close", () => {
89
+ this.onCancelHandler?.();
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Register handler for incoming messages
95
+ * @param handler - callback invoked with each deserialized request message
96
+ * @returns this instance for chaining
97
+ */
98
+ onMessage(handler: (message: RequestType) => void | Promise<void>): this {
99
+ this.onMessageHandler = handler;
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * Register handler for stream end (client finished sending)
105
+ * @param handler - callback invoked when the client closes the send side of the stream
106
+ * @returns this instance for chaining
107
+ */
108
+ onEnd(handler: () => void): this {
109
+ this.onEndHandler = handler;
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * Register handler for stream cancellation (client disconnected)
115
+ * @param handler - callback invoked when the underlying connection is closed by the client
116
+ * @returns this instance for chaining
117
+ */
118
+ onCancel(handler: () => void): this {
119
+ this.onCancelHandler = handler;
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Send a response message with gRPC framing.
125
+ * @param message - the response message to serialize and send
126
+ * @returns void
127
+ */
128
+ send(message: ResponseType): void {
129
+ const responseBuffer = this.definition.responseSerialize(message);
130
+ const frame = Buffer.alloc(5 + responseBuffer.length);
131
+ frame.writeUInt8(0, 0); // Not compressed
132
+ frame.writeUInt32BE(responseBuffer.length, 1);
133
+ responseBuffer.copy(frame, 5);
134
+ this.response.write(frame);
135
+ }
136
+
137
+ /**
138
+ * End the response with gRPC status trailers.
139
+ * @param status - gRPC status code (0 = OK)
140
+ * @param message - optional status message included in grpc-message trailer
141
+ * @returns void
142
+ */
143
+ end(status: number = 0, message?: string): void {
144
+ const trailers: Record<string, string> = { "grpc-status": String(status) };
145
+ if (message) {
146
+ trailers["grpc-message"] = encodeURIComponent(message);
147
+ }
148
+ (this.response as any).addTrailers?.(trailers);
149
+ this.response.end();
150
+ }
151
+
152
+ /**
153
+ * Send a single unary response and close the stream.
154
+ * @param message - the response message to serialize and send before closing
155
+ * @returns void
156
+ */
157
+ sendUnary(message: ResponseType): void {
158
+ this.send(message);
159
+ this.end(0);
160
+ }
161
+
162
+ /**
163
+ * Send an error response and close the stream.
164
+ * @param status - gRPC status code indicating the error type
165
+ * @param message - human-readable error message included in grpc-message trailer
166
+ * @returns void
167
+ */
168
+ sendError(status: number, message: string): void {
169
+ this.end(status, message);
170
+ }
171
+ }
172
+
173
+ /** gRPC status codes */
174
+ export const GrpcStatus = {
175
+ OK: 0,
176
+ CANCELLED: 1,
177
+ UNKNOWN: 2,
178
+ INVALID_ARGUMENT: 3,
179
+ DEADLINE_EXCEEDED: 4,
180
+ NOT_FOUND: 5,
181
+ ALREADY_EXISTS: 6,
182
+ PERMISSION_DENIED: 7,
183
+ RESOURCE_EXHAUSTED: 8,
184
+ FAILED_PRECONDITION: 9,
185
+ ABORTED: 10,
186
+ OUT_OF_RANGE: 11,
187
+ UNIMPLEMENTED: 12,
188
+ INTERNAL: 13,
189
+ UNAVAILABLE: 14,
190
+ DATA_LOSS: 15,
191
+ UNAUTHENTICATED: 16
192
+ } as const;