@zero-server/grpc 0.9.1 → 0.9.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,708 @@
1
+ /**
2
+ * @module grpc/call
3
+ * @description gRPC call objects for the four RPC patterns.
4
+ * Wraps HTTP/2 streams with protobuf encode/decode, metadata,
5
+ * framing, deadline enforcement, and cancellation support.
6
+ *
7
+ * - `UnaryCall` — single request, single response
8
+ * - `ServerStreamCall` — single request, stream of responses
9
+ * - `ClientStreamCall` — stream of requests, single response
10
+ * - `BidiStreamCall` — bidirectional streaming
11
+ *
12
+ * @example | Unary handler
13
+ * async function GetUser(call) {
14
+ * const user = await db.findById(call.request.id);
15
+ * return user; // auto-encoded and sent
16
+ * }
17
+ *
18
+ * @example | Server streaming handler
19
+ * async function ListUsers(call) {
20
+ * for (const user of users) {
21
+ * call.write(user);
22
+ * }
23
+ * call.end();
24
+ * }
25
+ *
26
+ * @example | Bidirectional streaming handler
27
+ * async function Chat(call) {
28
+ * for await (const msg of call) {
29
+ * call.write({ echo: msg.text, ts: Date.now() });
30
+ * }
31
+ * }
32
+ */
33
+
34
+ const { EventEmitter } = require('events');
35
+ const log = require('../debug')('zero:grpc');
36
+ const { GrpcStatus, statusName } = require('./status');
37
+ const { Metadata } = require('./metadata');
38
+ const { frameEncode, FrameParser } = require('./frame');
39
+ const { encode, decode } = require('./codec');
40
+
41
+ // -- Base Call ---------------------------------------------
42
+
43
+ /**
44
+ * Base class for all gRPC call types. Manages the HTTP/2 stream,
45
+ * metadata, deadlines, cancellation, and common lifecycle.
46
+ *
47
+ * @class
48
+ * @private
49
+ */
50
+ class BaseCall extends EventEmitter
51
+ {
52
+ /**
53
+ * @constructor
54
+ * @param {import('http2').Http2Stream} stream - The HTTP/2 stream for this call.
55
+ * @param {object} methodDef - Method descriptor from the proto schema.
56
+ * @param {object} messageTypes - Map of all message descriptors.
57
+ * @param {Metadata} metadata - Initial metadata from the client.
58
+ * @param {object} [opts] - Call options.
59
+ * @param {number} [opts.maxMessageSize] - Max message size in bytes.
60
+ * @param {boolean} [opts.compress=false] - Whether to compress outgoing messages.
61
+ */
62
+ constructor(stream, methodDef, messageTypes, metadata, opts = {})
63
+ {
64
+ super();
65
+
66
+ /** The underlying HTTP/2 stream. */
67
+ this.stream = stream;
68
+
69
+ /** Method descriptor from the parsed proto. */
70
+ this.method = methodDef;
71
+
72
+ /** Initial metadata from the client. */
73
+ this.metadata = metadata;
74
+
75
+ /** Response (trailing) metadata to be sent with the status. */
76
+ this.trailingMetadata = new Metadata();
77
+
78
+ /** @private */
79
+ this._messageTypes = messageTypes;
80
+ /** @private */
81
+ this._inputDesc = messageTypes[methodDef.inputType];
82
+ /** @private */
83
+ this._outputDesc = messageTypes[methodDef.outputType];
84
+ /** @private */
85
+ this._compress = !!opts.compress;
86
+ /** @private */
87
+ this._cancelled = false;
88
+ /** @private */
89
+ this._ended = false;
90
+ /** @private */
91
+ this._headersSent = false;
92
+ /** @private */
93
+ this._deadlineTimer = null;
94
+
95
+ if (!this._inputDesc)
96
+ throw new Error(`Unknown input message type: ${methodDef.inputType}`);
97
+ if (!this._outputDesc)
98
+ throw new Error(`Unknown output message type: ${methodDef.outputType}`);
99
+
100
+ /** @private */
101
+ this._parser = new FrameParser({ maxMessageSize: opts.maxMessageSize });
102
+
103
+ // Parse the deadline from grpc-timeout header
104
+ this._deadline = null;
105
+ const timeoutHeader = stream.sentHeaders && stream.sentHeaders['grpc-timeout'];
106
+ if (timeoutHeader) this._setupDeadline(timeoutHeader);
107
+
108
+ // Cancellation via stream reset
109
+ stream.on('close', () =>
110
+ {
111
+ if (!this._ended)
112
+ {
113
+ this._cancelled = true;
114
+ this.emit('cancelled');
115
+ }
116
+ this._cleanup();
117
+ });
118
+
119
+ stream.on('error', (err) =>
120
+ {
121
+ log.error('stream error on %s: %s', methodDef.name, err.message);
122
+ this.emit('error', err);
123
+ });
124
+
125
+ /** Peer IP address (from stream session). */
126
+ this.peer = stream.session && stream.session.socket
127
+ ? stream.session.socket.remoteAddress || 'unknown'
128
+ : 'unknown';
129
+ }
130
+
131
+ // -- Metadata Sending ----------------------------------
132
+
133
+ /**
134
+ * Send initial response metadata (HTTP/2 headers).
135
+ * Must be called before writing any messages.
136
+ * If not called explicitly, headers are sent automatically on the first write.
137
+ *
138
+ * @param {Metadata|object} [md] - Additional metadata to merge into the response headers.
139
+ */
140
+ sendMetadata(md)
141
+ {
142
+ if (this._headersSent || this._ended) return;
143
+ this._headersSent = true;
144
+
145
+ const headers = {
146
+ ':status': 200,
147
+ 'content-type': 'application/grpc+proto',
148
+ };
149
+
150
+ if (this._compress) headers['grpc-encoding'] = 'gzip';
151
+
152
+ if (md)
153
+ {
154
+ const extra = md instanceof Metadata ? md.toHeaders() : md;
155
+ Object.assign(headers, extra);
156
+ }
157
+
158
+ try { this.stream.respond(headers, { waitForTrailers: true }); }
159
+ catch (e) { log.warn('failed to send metadata: %s', e.message); }
160
+ }
161
+
162
+ // -- Status / End --------------------------------------
163
+
164
+ /**
165
+ * Send a gRPC status and close the call.
166
+ * Trailers carry `grpc-status` and optionally `grpc-message`.
167
+ *
168
+ * @param {number} code - gRPC status code.
169
+ * @param {string} [message] - Human-readable status message.
170
+ */
171
+ sendStatus(code, message)
172
+ {
173
+ if (this._ended) return;
174
+ this._ended = true;
175
+
176
+ const trailHeaders = {
177
+ 'grpc-status': String(code),
178
+ ...this.trailingMetadata.toHeaders(),
179
+ };
180
+ if (message) trailHeaders['grpc-message'] = encodeURIComponent(message);
181
+
182
+ if (!this._headersSent)
183
+ {
184
+ // Trailers-Only response — include status in initial HEADERS frame
185
+ this._headersSent = true;
186
+ try
187
+ {
188
+ this.stream.respond({
189
+ ':status': 200,
190
+ 'content-type': 'application/grpc+proto',
191
+ ...trailHeaders,
192
+ }, { endStream: true });
193
+ }
194
+ catch (_)
195
+ {
196
+ try { this.stream.close(); }
197
+ catch (__) { /* nothing to do */ }
198
+ }
199
+ }
200
+ else
201
+ {
202
+ // Headers already sent — send trailing HEADERS after final DATA
203
+ this.stream.on('wantTrailers', () =>
204
+ {
205
+ try { this.stream.sendTrailers(trailHeaders); }
206
+ catch (_) { /* stream may already be closed */ }
207
+ });
208
+
209
+ try { this.stream.end(); }
210
+ catch (_)
211
+ {
212
+ try { this.stream.close(); }
213
+ catch (__) { /* nothing to do */ }
214
+ }
215
+ }
216
+
217
+ this._cleanup();
218
+
219
+ if (code !== GrpcStatus.OK)
220
+ log.warn('call %s ended with status %s: %s', this.method.name, statusName(code), message || '');
221
+ else
222
+ log.debug('call %s completed OK', this.method.name);
223
+ }
224
+
225
+ /**
226
+ * Send an error status and close the call.
227
+ * Convenience wrapper around `sendStatus`.
228
+ *
229
+ * @param {number} code - gRPC status code.
230
+ * @param {string} message - Error description.
231
+ */
232
+ sendError(code, message)
233
+ {
234
+ this.sendStatus(code, message);
235
+ }
236
+
237
+ // -- Writing -------------------------------------------
238
+
239
+ /**
240
+ * Write a response message. The object is encoded to protobuf, framed,
241
+ * and sent on the HTTP/2 stream.
242
+ *
243
+ * @param {object} message - JavaScript object matching the output message schema.
244
+ * @returns {boolean} `false` if the stream is not writable.
245
+ */
246
+ write(message)
247
+ {
248
+ if (this._ended || this._cancelled) return false;
249
+ if (!this._headersSent) this.sendMetadata();
250
+
251
+ try
252
+ {
253
+ const buf = encode(message, this._outputDesc, this._messageTypes);
254
+ const frame = frameEncode(buf, { compress: this._compress });
255
+
256
+ if (frame instanceof Promise)
257
+ {
258
+ frame.then((f) =>
259
+ {
260
+ if (!this._ended && !this._cancelled)
261
+ this.stream.write(f);
262
+ }).catch((err) =>
263
+ {
264
+ log.error('compression error: %s', err.message);
265
+ this.sendError(GrpcStatus.INTERNAL, 'Compression failed');
266
+ });
267
+ return true;
268
+ }
269
+
270
+ return this.stream.write(frame);
271
+ }
272
+ catch (err)
273
+ {
274
+ log.error('encode error in %s: %s', this.method.name, err.message);
275
+ this.sendError(GrpcStatus.INTERNAL, 'Failed to encode response');
276
+ return false;
277
+ }
278
+ }
279
+
280
+ // -- Deadline ------------------------------------------
281
+
282
+ /**
283
+ * Parse and set up a deadline from the `grpc-timeout` header.
284
+ * Format: `{value}{unit}` where unit is n(nano), u(micro), m(milli), S(seconds), M(minutes), H(hours).
285
+ * @private
286
+ * @param {string} timeoutStr
287
+ */
288
+ _setupDeadline(timeoutStr)
289
+ {
290
+ const match = /^(\d+)([nmuSMH])$/.exec(timeoutStr);
291
+ if (!match)
292
+ {
293
+ log.warn('invalid grpc-timeout: %s', timeoutStr);
294
+ return;
295
+ }
296
+
297
+ const val = parseInt(match[1], 10);
298
+ let ms;
299
+
300
+ switch (match[2])
301
+ {
302
+ case 'n': ms = val / 1e6; break; // nanoseconds
303
+ case 'u': ms = val / 1e3; break; // microseconds
304
+ case 'm': ms = val; break; // milliseconds
305
+ case 'S': ms = val * 1000; break; // seconds
306
+ case 'M': ms = val * 60 * 1000; break; // minutes
307
+ case 'H': ms = val * 3600 * 1000; break; // hours
308
+ default: return;
309
+ }
310
+
311
+ this._deadline = Date.now() + ms;
312
+ this._deadlineTimer = setTimeout(() =>
313
+ {
314
+ if (!this._ended)
315
+ {
316
+ log.warn('deadline exceeded for %s (%dms)', this.method.name, ms);
317
+ this.sendError(GrpcStatus.DEADLINE_EXCEEDED, 'Deadline exceeded');
318
+ }
319
+ }, Math.max(1, ms));
320
+
321
+ if (this._deadlineTimer.unref) this._deadlineTimer.unref();
322
+ }
323
+
324
+ /**
325
+ * Check if the call has been cancelled.
326
+ *
327
+ * @returns {boolean}
328
+ */
329
+ get cancelled()
330
+ {
331
+ return this._cancelled;
332
+ }
333
+
334
+ /**
335
+ * Cancel the call from the server side.
336
+ */
337
+ cancel()
338
+ {
339
+ if (this._ended) return;
340
+ this._cancelled = true;
341
+ this.sendError(GrpcStatus.CANCELLED, 'Cancelled by server');
342
+ }
343
+
344
+ /**
345
+ * Clean up timers and references.
346
+ * @private
347
+ */
348
+ _cleanup()
349
+ {
350
+ if (this._deadlineTimer)
351
+ {
352
+ clearTimeout(this._deadlineTimer);
353
+ this._deadlineTimer = null;
354
+ }
355
+ this._parser.destroy();
356
+ }
357
+ }
358
+
359
+ // -- Unary Call --------------------------------------------
360
+
361
+ /**
362
+ * A unary gRPC call — single request message, single response message.
363
+ *
364
+ * @class
365
+ * @extends BaseCall
366
+ *
367
+ * @example
368
+ * // In a service handler:
369
+ * async function GetUser(call) {
370
+ * const user = await db.users.findById(call.request.id);
371
+ * if (!user) call.sendError(GrpcStatus.NOT_FOUND, 'User not found');
372
+ * return user; // returned value is sent as the response
373
+ * }
374
+ */
375
+ class UnaryCall extends BaseCall
376
+ {
377
+ /**
378
+ * @constructor
379
+ * @param {import('http2').Http2Stream} stream
380
+ * @param {object} methodDef
381
+ * @param {object} messageTypes
382
+ * @param {Metadata} metadata
383
+ * @param {object} [opts]
384
+ */
385
+ constructor(stream, methodDef, messageTypes, metadata, opts)
386
+ {
387
+ super(stream, methodDef, messageTypes, metadata, opts);
388
+
389
+ /** The decoded request message (populated after receiving the full request). */
390
+ this.request = null;
391
+ }
392
+
393
+ /**
394
+ * Initialize the call — collect the full request body and decode it.
395
+ * @private
396
+ * @returns {Promise<void>}
397
+ */
398
+ _init()
399
+ {
400
+ return new Promise((resolve, reject) =>
401
+ {
402
+ this._parser.onMessage = (buf) =>
403
+ {
404
+ try
405
+ {
406
+ this.request = decode(buf, this._inputDesc, this._messageTypes);
407
+ resolve();
408
+ }
409
+ catch (err) { reject(err); }
410
+ };
411
+ this._parser.onError = reject;
412
+
413
+ this.stream.on('data', (chunk) => this._parser.push(chunk));
414
+ this.stream.on('end', () =>
415
+ {
416
+ if (!this.request) resolve(); // empty body = default message
417
+ });
418
+ });
419
+ }
420
+ }
421
+
422
+ // -- Server Streaming Call ---------------------------------
423
+
424
+ /**
425
+ * A server-streaming gRPC call — single request, multiple responses.
426
+ * The handler calls `call.write(msg)` for each response and `call.end()` to finish.
427
+ *
428
+ * @class
429
+ * @extends BaseCall
430
+ *
431
+ * @example
432
+ * async function ListUsers(call) {
433
+ * const cursor = db.users.cursor();
434
+ * for await (const user of cursor) {
435
+ * call.write(user);
436
+ * }
437
+ * call.end();
438
+ * }
439
+ */
440
+ class ServerStreamCall extends BaseCall
441
+ {
442
+ constructor(stream, methodDef, messageTypes, metadata, opts)
443
+ {
444
+ super(stream, methodDef, messageTypes, metadata, opts);
445
+ this.request = null;
446
+ }
447
+
448
+ /**
449
+ * End the server stream with OK status.
450
+ */
451
+ end()
452
+ {
453
+ this.sendStatus(GrpcStatus.OK);
454
+ }
455
+
456
+ /** @private */
457
+ _init()
458
+ {
459
+ return new Promise((resolve, reject) =>
460
+ {
461
+ this._parser.onMessage = (buf) =>
462
+ {
463
+ try
464
+ {
465
+ this.request = decode(buf, this._inputDesc, this._messageTypes);
466
+ resolve();
467
+ }
468
+ catch (err) { reject(err); }
469
+ };
470
+ this._parser.onError = reject;
471
+
472
+ this.stream.on('data', (chunk) => this._parser.push(chunk));
473
+ this.stream.on('end', () =>
474
+ {
475
+ if (!this.request) resolve();
476
+ });
477
+ });
478
+ }
479
+ }
480
+
481
+ // -- Client Streaming Call ---------------------------------
482
+
483
+ /**
484
+ * A client-streaming gRPC call — multiple requests, single response.
485
+ * The handler iterates `for await (const msg of call)` to consume messages,
486
+ * then returns the response object.
487
+ *
488
+ * @class
489
+ * @extends BaseCall
490
+ *
491
+ * @example
492
+ * async function UploadChunks(call) {
493
+ * let total = 0;
494
+ * for await (const chunk of call) {
495
+ * total += chunk.data.length;
496
+ * }
497
+ * return { bytesReceived: total };
498
+ * }
499
+ */
500
+ class ClientStreamCall extends BaseCall
501
+ {
502
+ constructor(stream, methodDef, messageTypes, metadata, opts)
503
+ {
504
+ super(stream, methodDef, messageTypes, metadata, opts);
505
+
506
+ /** @private */
507
+ this._messageQueue = [];
508
+ /** @private */
509
+ this._messageResolve = null;
510
+ /** @private */
511
+ this._streamEnded = false;
512
+ }
513
+
514
+ /**
515
+ * Initialize — set up the frame parser to enqueue decoded messages.
516
+ * @private
517
+ */
518
+ _init()
519
+ {
520
+ this._parser.onMessage = (buf) =>
521
+ {
522
+ try
523
+ {
524
+ const msg = decode(buf, this._inputDesc, this._messageTypes);
525
+ if (this._messageResolve)
526
+ {
527
+ const resolve = this._messageResolve;
528
+ this._messageResolve = null;
529
+ resolve({ value: msg, done: false });
530
+ }
531
+ else
532
+ {
533
+ this._messageQueue.push(msg);
534
+ }
535
+ }
536
+ catch (err)
537
+ {
538
+ log.error('decode error in client stream: %s', err.message);
539
+ this.sendError(GrpcStatus.INTERNAL, 'Failed to decode client message');
540
+ }
541
+ };
542
+ this._parser.onError = (err) =>
543
+ {
544
+ this.sendError(GrpcStatus.INTERNAL, err.message);
545
+ };
546
+
547
+ this.stream.on('data', (chunk) => this._parser.push(chunk));
548
+ this.stream.on('end', () =>
549
+ {
550
+ this._streamEnded = true;
551
+ if (this._messageResolve)
552
+ {
553
+ const resolve = this._messageResolve;
554
+ this._messageResolve = null;
555
+ resolve({ value: undefined, done: true });
556
+ }
557
+ });
558
+
559
+ return Promise.resolve();
560
+ }
561
+
562
+ /**
563
+ * Async iterator — enables `for await (const msg of call)`.
564
+ *
565
+ * @returns {AsyncIterator<object>}
566
+ */
567
+ [Symbol.asyncIterator]()
568
+ {
569
+ return {
570
+ next: () =>
571
+ {
572
+ if (this._messageQueue.length > 0)
573
+ {
574
+ return Promise.resolve({ value: this._messageQueue.shift(), done: false });
575
+ }
576
+ if (this._streamEnded)
577
+ {
578
+ return Promise.resolve({ value: undefined, done: true });
579
+ }
580
+ return new Promise((resolve) =>
581
+ {
582
+ this._messageResolve = resolve;
583
+ });
584
+ },
585
+ };
586
+ }
587
+ }
588
+
589
+ // -- Bidirectional Streaming Call ---------------------------
590
+
591
+ /**
592
+ * A bidirectional streaming gRPC call — multiple requests AND multiple responses.
593
+ * The handler can `for await` incoming messages while simultaneously
594
+ * calling `call.write()` to send responses.
595
+ *
596
+ * @class
597
+ * @extends BaseCall
598
+ *
599
+ * @example
600
+ * async function Chat(call) {
601
+ * for await (const msg of call) {
602
+ * // Echo back with a timestamp
603
+ * call.write({ text: msg.text, ts: Date.now() });
604
+ * }
605
+ * call.end();
606
+ * }
607
+ */
608
+ class BidiStreamCall extends BaseCall
609
+ {
610
+ constructor(stream, methodDef, messageTypes, metadata, opts)
611
+ {
612
+ super(stream, methodDef, messageTypes, metadata, opts);
613
+
614
+ /** @private */
615
+ this._messageQueue = [];
616
+ /** @private */
617
+ this._messageResolve = null;
618
+ /** @private */
619
+ this._streamEnded = false;
620
+ }
621
+
622
+ /**
623
+ * End the bidirectional stream with OK status.
624
+ */
625
+ end()
626
+ {
627
+ this.sendStatus(GrpcStatus.OK);
628
+ }
629
+
630
+ /** @private */
631
+ _init()
632
+ {
633
+ this._parser.onMessage = (buf) =>
634
+ {
635
+ try
636
+ {
637
+ const msg = decode(buf, this._inputDesc, this._messageTypes);
638
+ if (this._messageResolve)
639
+ {
640
+ const resolve = this._messageResolve;
641
+ this._messageResolve = null;
642
+ resolve({ value: msg, done: false });
643
+ }
644
+ else
645
+ {
646
+ this._messageQueue.push(msg);
647
+ }
648
+ }
649
+ catch (err)
650
+ {
651
+ log.error('decode error in bidi stream: %s', err.message);
652
+ this.sendError(GrpcStatus.INTERNAL, 'Failed to decode message');
653
+ }
654
+ };
655
+ this._parser.onError = (err) =>
656
+ {
657
+ this.sendError(GrpcStatus.INTERNAL, err.message);
658
+ };
659
+
660
+ this.stream.on('data', (chunk) => this._parser.push(chunk));
661
+ this.stream.on('end', () =>
662
+ {
663
+ this._streamEnded = true;
664
+ if (this._messageResolve)
665
+ {
666
+ const resolve = this._messageResolve;
667
+ this._messageResolve = null;
668
+ resolve({ value: undefined, done: true });
669
+ }
670
+ });
671
+
672
+ return Promise.resolve();
673
+ }
674
+
675
+ /**
676
+ * Async iterator — enables `for await (const msg of call)`.
677
+ *
678
+ * @returns {AsyncIterator<object>}
679
+ */
680
+ [Symbol.asyncIterator]()
681
+ {
682
+ return {
683
+ next: () =>
684
+ {
685
+ if (this._messageQueue.length > 0)
686
+ {
687
+ return Promise.resolve({ value: this._messageQueue.shift(), done: false });
688
+ }
689
+ if (this._streamEnded)
690
+ {
691
+ return Promise.resolve({ value: undefined, done: true });
692
+ }
693
+ return new Promise((resolve) =>
694
+ {
695
+ this._messageResolve = resolve;
696
+ });
697
+ },
698
+ };
699
+ }
700
+ }
701
+
702
+ module.exports = {
703
+ BaseCall,
704
+ UnaryCall,
705
+ ServerStreamCall,
706
+ ClientStreamCall,
707
+ BidiStreamCall,
708
+ };