@zero-server/grpc 0.9.0 → 0.9.2

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,445 @@
1
+ /**
2
+ * @module grpc/server
3
+ * @description gRPC server for zero-server.
4
+ * Intercepts HTTP/2 streams with `content-type: application/grpc`,
5
+ * routes by `:path` pseudo-header (`/package.Service/Method`),
6
+ * and dispatches to registered service handlers.
7
+ *
8
+ * Handles all four gRPC call types:
9
+ * - Unary (single request → single response)
10
+ * - Server streaming (single request → multiple responses)
11
+ * - Client streaming (multiple requests → single response)
12
+ * - Bidirectional streaming (multiple requests ↔ multiple responses)
13
+ *
14
+ * Supports interceptors (server-side middleware), deadline enforcement,
15
+ * message size limits, and graceful shutdown with call draining.
16
+ *
17
+ * @example
18
+ * const { createApp, parseProto } = require('@zero-server/sdk');
19
+ * const app = createApp();
20
+ * const schema = parseProto(fs.readFileSync('hello.proto', 'utf8'));
21
+ *
22
+ * app.grpc(schema, 'Greeter', {
23
+ * SayHello(call) {
24
+ * return { message: 'Hello ' + call.request.name };
25
+ * },
26
+ * });
27
+ *
28
+ * app.listen(50051, { http2: true });
29
+ *
30
+ * @example | Server streaming
31
+ * app.grpc(schema, 'DataService', {
32
+ * StreamData(call) {
33
+ * for (let i = 0; i < 100; i++) {
34
+ * call.write({ seq: i, payload: 'chunk-' + i });
35
+ * }
36
+ * call.end();
37
+ * },
38
+ * });
39
+ *
40
+ * @example | Bidirectional streaming with interceptors
41
+ * app.grpc(schema, 'ChatService', {
42
+ * Chat(call) {
43
+ * for await (const msg of call) {
44
+ * call.write({ echo: msg.text, ts: Date.now() });
45
+ * }
46
+ * call.end();
47
+ * },
48
+ * }, {
49
+ * interceptors: [authInterceptor, loggingInterceptor],
50
+ * });
51
+ */
52
+
53
+ const log = require('../debug')('zero:grpc');
54
+ const { GrpcStatus, statusName } = require('./status');
55
+ const { Metadata } = require('./metadata');
56
+ const { UnaryCall, ServerStreamCall, ClientStreamCall, BidiStreamCall } = require('./call');
57
+
58
+ // -- Service Registry --------------------------------------
59
+
60
+ /**
61
+ * Registry of gRPC services and their handlers.
62
+ * Manages routing of HTTP/2 streams to the correct service method.
63
+ *
64
+ * @class
65
+ *
66
+ * @example
67
+ * const registry = new GrpcServiceRegistry();
68
+ * registry.addService(schema, 'Greeter', handlers, opts);
69
+ */
70
+ class GrpcServiceRegistry
71
+ {
72
+ constructor()
73
+ {
74
+ /**
75
+ * Map of path → handler descriptor.
76
+ * Keys are in the format `/package.ServiceName/MethodName`.
77
+ * @type {Map<string, { service: string, method: object, handler: Function, schema: object, opts: object }>}
78
+ */
79
+ this._routes = new Map();
80
+
81
+ /**
82
+ * Global interceptors applied to all services.
83
+ * @type {Function[]}
84
+ */
85
+ this._interceptors = [];
86
+
87
+ /**
88
+ * Active calls for graceful shutdown draining.
89
+ * @type {Set<import('./call').BaseCall>}
90
+ */
91
+ this._activeCalls = new Set();
92
+
93
+ /**
94
+ * Whether the server is draining (rejecting new calls).
95
+ * @type {boolean}
96
+ */
97
+ this._draining = false;
98
+ }
99
+
100
+ /**
101
+ * Register a service with its handlers.
102
+ *
103
+ * @param {object} schema - Parsed proto schema from `parseProto()`.
104
+ * @param {string} serviceName - Name of the service as defined in the proto file.
105
+ * @param {Object<string, Function>} handlers - Map of method names to handler functions.
106
+ * @param {object} [opts] - Service options.
107
+ * @param {Function[]} [opts.interceptors] - Per-service interceptors.
108
+ * @param {number} [opts.maxMessageSize] - Max incoming message size in bytes.
109
+ * @param {boolean} [opts.compress=false] - Whether to compress outgoing messages.
110
+ *
111
+ * @example
112
+ * registry.addService(schema, 'Greeter', {
113
+ * SayHello(call) { return { message: 'Hello ' + call.request.name }; },
114
+ * });
115
+ */
116
+ addService(schema, serviceName, handlers, opts = {})
117
+ {
118
+ const service = schema.services[serviceName];
119
+ if (!service)
120
+ {
121
+ throw new Error(`Service "${serviceName}" not found in proto schema. ` +
122
+ `Available: ${Object.keys(schema.services).join(', ') || 'none'}`);
123
+ }
124
+
125
+ // Build the package prefix for routing
126
+ const packagePrefix = schema.package ? schema.package + '.' : '';
127
+ const pathPrefix = '/' + packagePrefix + serviceName;
128
+
129
+ for (const [methodName, methodDef] of Object.entries(service.methods))
130
+ {
131
+ if (!handlers[methodName])
132
+ {
133
+ log.warn('no handler for %s/%s — will return UNIMPLEMENTED', serviceName, methodName);
134
+ }
135
+
136
+ const routePath = pathPrefix + '/' + methodName;
137
+ this._routes.set(routePath, {
138
+ service: serviceName,
139
+ method: methodDef,
140
+ handler: handlers[methodName] || null,
141
+ schema,
142
+ opts,
143
+ });
144
+
145
+ log.info('registered gRPC method %s [%s]', routePath,
146
+ _callType(methodDef));
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Add a global interceptor that runs before every gRPC call.
152
+ * Interceptors receive `(call, next)` and must call `next()` to continue.
153
+ *
154
+ * @param {Function} fn - Interceptor function `(call, next) => void`.
155
+ *
156
+ * @example
157
+ * registry.addInterceptor(async (call, next) => {
158
+ * const token = call.metadata.get('authorization');
159
+ * if (!token) return call.sendError(GrpcStatus.UNAUTHENTICATED, 'Missing auth');
160
+ * await next();
161
+ * });
162
+ */
163
+ addInterceptor(fn)
164
+ {
165
+ this._interceptors.push(fn);
166
+ }
167
+
168
+ /**
169
+ * Handle an incoming HTTP/2 stream. Determines if it's a gRPC call,
170
+ * routes to the correct handler, and manages the call lifecycle.
171
+ *
172
+ * @param {import('http2').Http2Stream} stream - The HTTP/2 stream.
173
+ * @param {object} headers - HTTP/2 headers from the stream event.
174
+ * @returns {boolean} `true` if this was handled as a gRPC call.
175
+ */
176
+ handleStream(stream, headers)
177
+ {
178
+ const contentType = headers['content-type'] || '';
179
+ if (!contentType.startsWith('application/grpc'))
180
+ {
181
+ return false; // Not a gRPC request — let the normal HTTP pipeline handle it
182
+ }
183
+
184
+ const grpcPath = headers[':path'];
185
+ const method = headers[':method'];
186
+
187
+ // gRPC always uses POST
188
+ if (method !== 'POST')
189
+ {
190
+ _sendError(stream, GrpcStatus.UNIMPLEMENTED,
191
+ 'gRPC requires POST method');
192
+ return true;
193
+ }
194
+
195
+ // Reject new calls during shutdown drain
196
+ if (this._draining)
197
+ {
198
+ _sendError(stream, GrpcStatus.UNAVAILABLE,
199
+ 'Server is shutting down');
200
+ return true;
201
+ }
202
+
203
+ // Look up the route
204
+ const route = this._routes.get(grpcPath);
205
+ if (!route)
206
+ {
207
+ log.warn('unregistered gRPC path: %s', grpcPath);
208
+ _sendError(stream, GrpcStatus.UNIMPLEMENTED,
209
+ `Method not found: ${grpcPath}`);
210
+ return true;
211
+ }
212
+
213
+ if (!route.handler)
214
+ {
215
+ _sendError(stream, GrpcStatus.UNIMPLEMENTED,
216
+ `Method not implemented: ${grpcPath}`);
217
+ return true;
218
+ }
219
+
220
+ // Dispatch the call
221
+ this._dispatch(stream, headers, route)
222
+ .catch((err) =>
223
+ {
224
+ log.error('unhandled error in gRPC handler %s: %s', grpcPath, err.message);
225
+ });
226
+
227
+ return true;
228
+ }
229
+
230
+ /**
231
+ * Dispatch a gRPC call to the appropriate handler.
232
+ * @private
233
+ * @param {import('http2').Http2Stream} stream
234
+ * @param {object} headers
235
+ * @param {object} route
236
+ */
237
+ async _dispatch(stream, headers, route)
238
+ {
239
+ const { method: methodDef, handler, schema, opts } = route;
240
+
241
+ // Parse metadata from headers
242
+ const metadata = Metadata.fromHeaders(headers);
243
+
244
+ // Create the appropriate call object
245
+ const CallClass = _pickCallClass(methodDef);
246
+ const call = new CallClass(stream, methodDef, schema.messages, metadata, {
247
+ maxMessageSize: opts.maxMessageSize,
248
+ compress: opts.compress,
249
+ });
250
+
251
+ // Track for graceful shutdown
252
+ this._activeCalls.add(call);
253
+ stream.on('close', () => this._activeCalls.delete(call));
254
+
255
+ try
256
+ {
257
+ // Initialize the call (collect request body / set up streaming)
258
+ await call._init();
259
+
260
+ // Run interceptors + handler
261
+ const interceptors = [
262
+ ...this._interceptors,
263
+ ...(opts.interceptors || []),
264
+ ];
265
+
266
+ await _runInterceptors(interceptors, call, async () =>
267
+ {
268
+ if (call.cancelled) return;
269
+
270
+ const result = await handler(call);
271
+
272
+ // For unary and client-streaming: if the handler returned
273
+ // a value, send it as the response automatically
274
+ if (result !== undefined && result !== null && !call._ended)
275
+ {
276
+ call.write(result);
277
+ call.sendStatus(GrpcStatus.OK);
278
+ }
279
+ });
280
+ }
281
+ catch (err)
282
+ {
283
+ log.error('gRPC handler error in %s: %s', methodDef.name, err.message);
284
+ if (!call._ended)
285
+ {
286
+ const code = err.grpcCode || GrpcStatus.INTERNAL;
287
+ call.sendError(code, err.message);
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Begin draining — reject new calls and wait for active calls to finish.
294
+ *
295
+ * @param {number} [timeout=30000] - Maximum time to wait in ms.
296
+ * @returns {Promise<void>}
297
+ */
298
+ async drain(timeout = 30000)
299
+ {
300
+ this._draining = true;
301
+ log.info('gRPC draining, %d active calls', this._activeCalls.size);
302
+
303
+ if (this._activeCalls.size === 0) return;
304
+
305
+ return new Promise((resolve) =>
306
+ {
307
+ const check = () =>
308
+ {
309
+ if (this._activeCalls.size === 0)
310
+ {
311
+ clearTimeout(timer);
312
+ resolve();
313
+ }
314
+ };
315
+
316
+ // Check periodically
317
+ const interval = setInterval(check, 100);
318
+ if (interval.unref) interval.unref();
319
+
320
+ const timer = setTimeout(() =>
321
+ {
322
+ clearInterval(interval);
323
+ log.warn('gRPC drain timed out with %d active calls', this._activeCalls.size);
324
+ // Force-close remaining calls
325
+ for (const call of this._activeCalls)
326
+ {
327
+ call.sendError(GrpcStatus.UNAVAILABLE, 'Server shutting down');
328
+ }
329
+ resolve();
330
+ }, timeout);
331
+ if (timer.unref) timer.unref();
332
+ });
333
+ }
334
+
335
+ /**
336
+ * Get all registered routes for introspection.
337
+ *
338
+ * @returns {{ method: string, path: string, type: string }[]}
339
+ *
340
+ * @example
341
+ * registry.routes();
342
+ * // [{ method: 'GRPC', path: '/myapp.Greeter/SayHello', type: 'unary' }]
343
+ */
344
+ routes()
345
+ {
346
+ const list = [];
347
+ for (const [path, route] of this._routes)
348
+ {
349
+ list.push({
350
+ method: 'GRPC',
351
+ path,
352
+ type: _callType(route.method),
353
+ implemented: !!route.handler,
354
+ });
355
+ }
356
+ return list;
357
+ }
358
+ }
359
+
360
+ // -- Helpers -----------------------------------------------
361
+
362
+ /**
363
+ * Pick the Call class based on method streaming flags.
364
+ * @private
365
+ * @param {object} methodDef
366
+ * @returns {typeof import('./call').BaseCall}
367
+ */
368
+ function _pickCallClass(methodDef)
369
+ {
370
+ if (methodDef.clientStreaming && methodDef.serverStreaming) return BidiStreamCall;
371
+ if (methodDef.clientStreaming) return ClientStreamCall;
372
+ if (methodDef.serverStreaming) return ServerStreamCall;
373
+ return UnaryCall;
374
+ }
375
+
376
+ /**
377
+ * Describe the call type for logging/introspection.
378
+ * @private
379
+ * @param {object} methodDef
380
+ * @returns {string}
381
+ */
382
+ function _callType(methodDef)
383
+ {
384
+ if (methodDef.clientStreaming && methodDef.serverStreaming) return 'bidi';
385
+ if (methodDef.clientStreaming) return 'client-stream';
386
+ if (methodDef.serverStreaming) return 'server-stream';
387
+ return 'unary';
388
+ }
389
+
390
+ /**
391
+ * Run a chain of interceptors with final handler.
392
+ * @private
393
+ * @param {Function[]} interceptors
394
+ * @param {import('./call').BaseCall} call
395
+ * @param {Function} finalHandler
396
+ */
397
+ async function _runInterceptors(interceptors, call, finalHandler)
398
+ {
399
+ let idx = 0;
400
+
401
+ async function next()
402
+ {
403
+ if (call._ended || call._cancelled) return;
404
+ if (idx < interceptors.length)
405
+ {
406
+ const fn = interceptors[idx++];
407
+ await fn(call, next);
408
+ }
409
+ else
410
+ {
411
+ await finalHandler();
412
+ }
413
+ }
414
+
415
+ await next();
416
+ }
417
+
418
+ /**
419
+ * Send a gRPC error on a raw HTTP/2 stream (before a Call object is created).
420
+ * @private
421
+ * @param {import('http2').Http2Stream} stream
422
+ * @param {number} code
423
+ * @param {string} message
424
+ */
425
+ function _sendError(stream, code, message)
426
+ {
427
+ try
428
+ {
429
+ stream.respond({
430
+ ':status': 200,
431
+ 'content-type': 'application/grpc+proto',
432
+ 'grpc-status': String(code),
433
+ 'grpc-message': encodeURIComponent(message),
434
+ }, { endStream: true });
435
+ }
436
+ catch (_)
437
+ {
438
+ try { stream.close(); }
439
+ catch (__) { /* stream already closed */ }
440
+ }
441
+ }
442
+
443
+ module.exports = {
444
+ GrpcServiceRegistry,
445
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @module grpc/status
3
+ * @description Standard gRPC status codes (as defined by the gRPC specification).
4
+ * Each code has a numeric value, a name, and a human-readable description.
5
+ * Used by both server and client to communicate call outcomes via trailers.
6
+ *
7
+ * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html
8
+ */
9
+
10
+ const log = require('../debug')('zero:grpc');
11
+
12
+ // -- Status Codes ------------------------------------------
13
+
14
+ /**
15
+ * gRPC status code enum. Mirrors the canonical codes from the gRPC spec.
16
+ *
17
+ * @enum {number}
18
+ *
19
+ * @example
20
+ * const { GrpcStatus } = require('@zero-server/sdk');
21
+ * call.sendError(GrpcStatus.NOT_FOUND, 'User not found');
22
+ */
23
+ const GrpcStatus = {
24
+ /** The operation completed successfully. */
25
+ OK: 0,
26
+ /** The operation was cancelled (typically by the caller). */
27
+ CANCELLED: 1,
28
+ /** Unknown error — a catch-all for unexpected failures. */
29
+ UNKNOWN: 2,
30
+ /** The client specified an invalid argument. */
31
+ INVALID_ARGUMENT: 3,
32
+ /** The deadline expired before the operation could complete. */
33
+ DEADLINE_EXCEEDED: 4,
34
+ /** The requested entity was not found. */
35
+ NOT_FOUND: 5,
36
+ /** The entity that a client attempted to create already exists. */
37
+ ALREADY_EXISTS: 6,
38
+ /** The caller does not have permission to execute the operation. */
39
+ PERMISSION_DENIED: 7,
40
+ /** Some resource has been exhausted (e.g. quota, disk space). */
41
+ RESOURCE_EXHAUSTED: 8,
42
+ /** The operation was rejected because the system is not in a required state. */
43
+ FAILED_PRECONDITION: 9,
44
+ /** The operation was aborted, typically due to a concurrency conflict. */
45
+ ABORTED: 10,
46
+ /** The operation was attempted past the valid range. */
47
+ OUT_OF_RANGE: 11,
48
+ /** The operation is not implemented or not supported. */
49
+ UNIMPLEMENTED: 12,
50
+ /** Internal error — invariants expected by the server have been broken. */
51
+ INTERNAL: 13,
52
+ /** The service is currently unavailable, usually a transient condition. */
53
+ UNAVAILABLE: 14,
54
+ /** Unrecoverable data loss or corruption. */
55
+ DATA_LOSS: 15,
56
+ /** The request does not have valid authentication credentials. */
57
+ UNAUTHENTICATED: 16,
58
+ };
59
+
60
+ /**
61
+ * Reverse lookup: number → string name.
62
+ * @type {Object<number, string>}
63
+ */
64
+ const STATUS_NAMES = {};
65
+ for (const [name, code] of Object.entries(GrpcStatus))
66
+ {
67
+ STATUS_NAMES[code] = name;
68
+ }
69
+
70
+ /**
71
+ * Map gRPC status code to the appropriate HTTP/2 status code for trailers-only responses.
72
+ *
73
+ * @param {number} grpcCode - gRPC status code.
74
+ * @returns {number} Corresponding HTTP status code.
75
+ */
76
+ function grpcToHttp(grpcCode)
77
+ {
78
+ switch (grpcCode)
79
+ {
80
+ case GrpcStatus.OK: return 200;
81
+ case GrpcStatus.INVALID_ARGUMENT: return 400;
82
+ case GrpcStatus.FAILED_PRECONDITION: return 400;
83
+ case GrpcStatus.OUT_OF_RANGE: return 400;
84
+ case GrpcStatus.UNAUTHENTICATED: return 401;
85
+ case GrpcStatus.PERMISSION_DENIED: return 403;
86
+ case GrpcStatus.NOT_FOUND: return 404;
87
+ case GrpcStatus.ALREADY_EXISTS: return 409;
88
+ case GrpcStatus.ABORTED: return 409;
89
+ case GrpcStatus.RESOURCE_EXHAUSTED: return 429;
90
+ case GrpcStatus.CANCELLED: return 499;
91
+ case GrpcStatus.UNIMPLEMENTED: return 501;
92
+ case GrpcStatus.UNAVAILABLE: return 503;
93
+ case GrpcStatus.DEADLINE_EXCEEDED: return 504;
94
+ case GrpcStatus.UNKNOWN:
95
+ case GrpcStatus.INTERNAL:
96
+ case GrpcStatus.DATA_LOSS:
97
+ default:
98
+ return 500;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get the human-readable name for a gRPC status code.
104
+ *
105
+ * @param {number} code - gRPC status code.
106
+ * @returns {string} Status name or 'UNKNOWN'.
107
+ */
108
+ function statusName(code)
109
+ {
110
+ return STATUS_NAMES[code] || 'UNKNOWN';
111
+ }
112
+
113
+ module.exports = {
114
+ GrpcStatus,
115
+ STATUS_NAMES,
116
+ grpcToHttp,
117
+ statusName,
118
+ };