@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.
- package/.webda/cache +4 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +166 -0
- package/README.md +100 -0
- package/lib/grpc-stream.d.ts +109 -0
- package/lib/grpc-stream.d.ts.map +1 -0
- package/lib/grpc-stream.js +162 -0
- package/lib/grpc-stream.js.map +1 -0
- package/lib/grpcservice.service.d.ts +112 -0
- package/lib/grpcservice.service.d.ts.map +1 -0
- package/lib/grpcservice.service.js +459 -0
- package/lib/grpcservice.service.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -0
- package/lib/proto-generator.d.ts +14 -0
- package/lib/proto-generator.d.ts.map +1 -0
- package/lib/proto-generator.js +312 -0
- package/lib/proto-generator.js.map +1 -0
- package/package.json +42 -0
- package/src/build-integration.spec.ts +187 -0
- package/src/grpc-stream.spec.ts +417 -0
- package/src/grpc-stream.ts +192 -0
- package/src/grpcservice.service.ts +462 -0
- package/src/grpcservice.spec.ts +1092 -0
- package/src/index.ts +4 -0
- package/src/proto-generator.spec.ts +696 -0
- package/src/proto-generator.ts +338 -0
- package/tsconfig.json +33 -0
- package/vitest.config.ts +20 -0
- package/webda.module.json +111 -0
|
@@ -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;
|