@zero-server/core 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.
package/lib/app.js ADDED
@@ -0,0 +1,1172 @@
1
+ /**
2
+ * @module app
3
+ * @description HTTP application with middleware pipeline,
4
+ * method-based routing, HTTP/2, HTTPS, and HTTP/1.1 support,
5
+ * built-in WebSocket upgrade handling, gRPC service hosting,
6
+ * trust proxy resolution, and route introspection.
7
+ * Created via `createApp()` in the public API.
8
+ *
9
+ * @example
10
+ * const { createApp } = require('@zero-server/sdk');
11
+ * const app = createApp();
12
+ *
13
+ * app.use(logger());
14
+ * app.get('/hello', (req, res) => res.json({ hello: 'world' }));
15
+ * app.listen(3000);
16
+ *
17
+ * @example | HTTP/2 with TLS
18
+ * const fs = require('fs');
19
+ * app.listen(443, {
20
+ * http2: true,
21
+ * key: fs.readFileSync('key.pem'),
22
+ * cert: fs.readFileSync('cert.pem'),
23
+ * });
24
+ *
25
+ * @example | HTTP/2 Cleartext
26
+ * app.listen(3000, { http2: true });
27
+ */
28
+ const http = require('http');
29
+ const https = require('https');
30
+ const http2 = require('http2');
31
+ const Router = require('./router');
32
+ const { Request, Response } = require('./http');
33
+ const { handleUpgrade } = require('./ws');
34
+ const { GrpcServiceRegistry } = require('./grpc/server');
35
+ const { LifecycleManager, LIFECYCLE_STATE } = require('./lifecycle');
36
+ const { healthCheck, createHealthHandlers, memoryCheck, eventLoopCheck, diskSpaceCheck } = require('./observe/health');
37
+ const { MetricsRegistry, createDefaultMetrics, metricsMiddleware, metricsEndpoint } = require('./observe/metrics');
38
+ const { Tracer, tracingMiddleware, instrumentFetch } = require('./observe/tracing');
39
+ const log = require('./debug')('zero:app');
40
+
41
+ class App
42
+ {
43
+ /**
44
+ * Create a new App instance.
45
+ * Initialises an empty middleware stack, a `Router`, and binds
46
+ * `this.handler` for direct use with `http.createServer()`.
47
+ *
48
+ * @constructor
49
+ */
50
+ constructor()
51
+ {
52
+ /** @type {Router} */
53
+ this.router = new Router();
54
+ /** @type {Function[]} */
55
+ this.middlewares = [];
56
+ /** @type {Function|null} */
57
+ this._errorHandler = null;
58
+ /** @type {Map<string, { handler: Function, opts: object }>} WebSocket upgrade handlers keyed by path */
59
+ this._wsHandlers = new Map();
60
+ /** @type {GrpcServiceRegistry|null} gRPC service registry (lazily created). */
61
+ this._grpcRegistry = null;
62
+ /** @type {import('./grpc/health').HealthService|null} gRPC health service. */
63
+ this._healthService = null;
64
+ /** @type {import('./grpc/reflection').ReflectionService|null} gRPC reflection service. */
65
+ this._reflectionService = null;
66
+ /** @type {import('http').Server|import('https').Server|null} */
67
+ this._server = null;
68
+
69
+ /**
70
+ * Application-level settings store.
71
+ * @type {Object<string, *>}
72
+ * @private
73
+ */
74
+ this._settings = {};
75
+
76
+ /**
77
+ * Application-level locals — persistent across the app lifecycle.
78
+ * Merged into every `req.locals` and `res.locals` on every request.
79
+ * @type {Object<string, *>}
80
+ */
81
+ this.locals = {};
82
+
83
+ /**
84
+ * Parameter pre-processing handlers.
85
+ * @type {Object<string, Function[]>}
86
+ * @private
87
+ */
88
+ this._paramHandlers = {};
89
+
90
+ /**
91
+ * Lifecycle manager for graceful shutdown and connection tracking.
92
+ * @type {LifecycleManager}
93
+ * @private
94
+ */
95
+ this._lifecycle = new LifecycleManager(this);
96
+
97
+ // Bind for use as `http.createServer(app.handler)`
98
+ this.handler = (req, res) => this.handle(req, res);
99
+ }
100
+
101
+ // -- Settings ------------------------------------
102
+
103
+ /**
104
+ * Set an application setting, or retrieve one when called with a single argument.
105
+ *
106
+ * When called with two arguments, sets the value and returns `this` for chaining.
107
+ * When called with one argument, returns the stored value.
108
+ *
109
+ * Common settings: `'trust proxy'`, `'env'`, `'json spaces'`, `'etag'`,
110
+ * `'view engine'`, `'views'`, `'case sensitive routing'`.
111
+ *
112
+ * @param {string} key - Setting name.
113
+ * @param {*} [val] - Setting value.
114
+ * @returns {*|App} The stored value (getter) or `this` (setter).
115
+ *
116
+ * @example
117
+ * app.set('trust proxy', true);
118
+ * app.set('json spaces', 2);
119
+ * app.set('env'); // => undefined (or previously set value)
120
+ */
121
+ set(key, val)
122
+ {
123
+ if (arguments.length === 1) return this._settings[key];
124
+ this._settings[key] = val;
125
+ return this;
126
+ }
127
+
128
+ /**
129
+ * Set a boolean setting to `true`.
130
+ *
131
+ * @param {string} key - Setting name.
132
+ * @returns {App} `this` for chaining.
133
+ *
134
+ * @example
135
+ * app.enable('trust proxy');
136
+ */
137
+ enable(key) { this._settings[key] = true; return this; }
138
+
139
+ /**
140
+ * Set a boolean setting to `false`.
141
+ *
142
+ * @param {string} key - Setting name.
143
+ * @returns {App} `this` for chaining.
144
+ *
145
+ * @example
146
+ * app.disable('etag');
147
+ */
148
+ disable(key) { this._settings[key] = false; return this; }
149
+
150
+ /**
151
+ * Check if a setting is truthy.
152
+ *
153
+ * @param {string} key - Setting name.
154
+ * @returns {boolean} `true` if the setting is truthy.
155
+ */
156
+ enabled(key) { return !!this._settings[key]; }
157
+
158
+ /**
159
+ * Check if a setting is falsy.
160
+ *
161
+ * @param {string} key - Setting name.
162
+ * @returns {boolean} `true` if the setting is falsy.
163
+ */
164
+ disabled(key) { return !this._settings[key]; }
165
+
166
+ // -- Middleware -------------------------------------
167
+
168
+ /**
169
+ * Register middleware or mount a sub-router.
170
+ * - `use(fn)` — global middleware applied to every request.
171
+ * - `use('/prefix', fn)` — path-scoped middleware (strips the prefix
172
+ * before calling `fn` so downstream sees relative paths).
173
+ * - `use('/prefix', router)` — mount a Router sub-app at the given prefix.
174
+ *
175
+ * @param {string|Function} pathOrFn - A path prefix string, or middleware function.
176
+ * @param {Function|Router} [fn] - Middleware function or Router when first arg is a path.
177
+ */
178
+ use(pathOrFn, fn)
179
+ {
180
+ if (typeof pathOrFn === 'function')
181
+ {
182
+ this.middlewares.push(pathOrFn);
183
+ }
184
+ else if (typeof pathOrFn === 'string' && fn instanceof Router)
185
+ {
186
+ // Mount a sub-router
187
+ this.router.use(pathOrFn, fn);
188
+ }
189
+ else if (typeof pathOrFn === 'string' && typeof fn === 'function')
190
+ {
191
+ const prefix = pathOrFn.endsWith('/') ? pathOrFn.slice(0, -1) : pathOrFn;
192
+ this.middlewares.push((req, res, next) =>
193
+ {
194
+ const urlPath = req.url.split('?')[0];
195
+ if (urlPath === prefix || urlPath.startsWith(prefix + '/'))
196
+ {
197
+ // strip prefix from url so downstream sees relative paths
198
+ const origUrl = req.url;
199
+ req.url = req.url.slice(prefix.length) || '/';
200
+ fn(req, res, () => { req.url = origUrl; next(); });
201
+ }
202
+ else
203
+ {
204
+ next();
205
+ }
206
+ });
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Register a global error handler.
212
+ * The handler receives `(err, req, res, next)` and is invoked whenever
213
+ * a middleware or route handler throws or passes an error to `next(err)`.
214
+ *
215
+ * @param {Function} fn - Error-handling function `(err, req, res, next) => void`.
216
+ */
217
+ onError(fn)
218
+ {
219
+ this._errorHandler = fn;
220
+ }
221
+
222
+ /**
223
+ * Register a parameter pre-processing handler.
224
+ * Runs before route handlers for any route containing a `:name` parameter.
225
+ *
226
+ * @param {string} name - Parameter name.
227
+ * @param {Function} fn - `(req, res, next, value) => void`.
228
+ * @returns {App} `this` for chaining.
229
+ *
230
+ * @example
231
+ * app.param('userId', async (req, res, next, id) => {
232
+ * req.locals.user = await db.users.findById(id);
233
+ * if (!req.locals.user) return res.status(404).json({ error: 'User not found' });
234
+ * next();
235
+ * });
236
+ */
237
+ param(name, fn)
238
+ {
239
+ if (!this._paramHandlers[name]) this._paramHandlers[name] = [];
240
+ this._paramHandlers[name].push(fn);
241
+ this.router._paramHandlers = this._paramHandlers;
242
+ return this;
243
+ }
244
+
245
+ // -- Request Handling ------------------------------
246
+
247
+ /**
248
+ * Core request handler. Wraps the raw Node `req`/`res` in
249
+ * `Request`/`Response` wrappers, runs the middleware
250
+ * pipeline, then falls through to the router.
251
+ *
252
+ * @param {import('http').IncomingMessage} req - Raw Node request.
253
+ * @param {import('http').ServerResponse} res - Raw Node response.
254
+ */
255
+ handle(req, res)
256
+ {
257
+ // Skip gRPC requests — already handled via server.on('stream')
258
+ if (this._grpcRegistry && req.headers['content-type']?.startsWith('application/grpc'))
259
+ return;
260
+
261
+ // Reject new requests during shutdown drain
262
+ if (this._lifecycle.isDraining)
263
+ {
264
+ // HTTP/2 doesn't use Connection header
265
+ const headers = {
266
+ 'Content-Type': 'application/json',
267
+ 'Retry-After': '5',
268
+ };
269
+ if (req.httpVersionMajor < 2) headers['Connection'] = 'close';
270
+ res.writeHead(503, headers);
271
+ res.end(JSON.stringify({ error: 'Service Unavailable', message: 'Server is shutting down' }));
272
+ return;
273
+ }
274
+
275
+ // Track active request for graceful shutdown
276
+ this._lifecycle.trackRequest(res);
277
+
278
+ const request = new Request(req);
279
+ const response = new Response(res);
280
+
281
+ // Inject app reference into request and response
282
+ request.app = this;
283
+ response.app = this;
284
+ response._req = request;
285
+ request._res = response;
286
+
287
+ // Preserve original URL before any middleware rewrites
288
+ request.originalUrl = request.url;
289
+
290
+ // Merge app.locals into request/response locals via prototype chain (avoids copy per request)
291
+ request.locals = Object.create(this.locals);
292
+ response.locals = Object.create(this.locals);
293
+
294
+ let idx = 0;
295
+ const run = (err) =>
296
+ {
297
+ if (err)
298
+ {
299
+ log.error('middleware error: %s', err.message || err);
300
+ if (this._errorHandler) return this._errorHandler(err, request, response, run);
301
+ response.status(500).json({ error: err.message || 'Internal Server Error' });
302
+ return;
303
+ }
304
+ if (idx < this.middlewares.length)
305
+ {
306
+ const mw = this.middlewares[idx++];
307
+ try
308
+ {
309
+ const result = mw(request, response, run);
310
+ // Handle promise-returning middleware
311
+ if (result && typeof result.catch === 'function')
312
+ {
313
+ result.catch(run);
314
+ }
315
+ }
316
+ catch (e)
317
+ {
318
+ run(e);
319
+ }
320
+ return;
321
+ }
322
+ this.router.handle(request, response);
323
+ };
324
+
325
+ run();
326
+ }
327
+
328
+ // -- Server Lifecycle ------------------------------
329
+
330
+ /**
331
+ * Start listening for HTTP, HTTPS, or HTTP/2 connections.
332
+ *
333
+ * @param {number} [port=3000] - Port number to bind.
334
+ * @param {object|Function} [opts] - Server options or callback.
335
+ * @param {boolean} [opts.http2] - Create an HTTP/2 server.
336
+ * @param {Buffer|string} [opts.key] - Private key for TLS (HTTPS or HTTP/2 with TLS).
337
+ * @param {Buffer|string} [opts.cert] - Certificate for TLS.
338
+ * @param {Buffer|string} [opts.pfx] - PFX/PKCS12 bundle (alternative to key+cert).
339
+ * @param {Buffer|string} [opts.ca] - CA certificate(s) for client verification.
340
+ * @param {boolean} [opts.allowHTTP1=true] - Allow HTTP/1.1 fallback on HTTP/2 secure servers (ALPN).
341
+ * @param {object} [opts.settings] - HTTP/2 settings (SETTINGS frame values).
342
+ * @param {Function} [cb] - Callback invoked once the server is listening.
343
+ * @returns {import('http').Server|import('https').Server|import('http2').Http2SecureServer|import('http2').Http2Server}
344
+ *
345
+ * @example | Plain HTTP
346
+ * app.listen(3000, () => console.log('HTTP on 3000'));
347
+ *
348
+ * @example | HTTPS
349
+ * app.listen(443, { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') });
350
+ *
351
+ * @example | HTTP/2 with TLS and ALPN
352
+ * app.listen(443, { http2: true, key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') });
353
+ *
354
+ * @example | HTTP/2 Cleartext
355
+ * app.listen(3000, { http2: true });
356
+ */
357
+ listen(port = 3000, opts, cb)
358
+ {
359
+ // Normalise arguments — allow `listen(port, cb)` without opts
360
+ if (typeof opts === 'function') { cb = opts; opts = undefined; }
361
+
362
+ const isH2 = opts && opts.http2;
363
+ const hasTLS = opts && (opts.key || opts.pfx || opts.cert);
364
+ let server;
365
+
366
+ if (isH2 && hasTLS)
367
+ {
368
+ // HTTP/2 over TLS with ALPN negotiation (h2 + HTTP/1.1 fallback)
369
+ const h2Opts = {
370
+ ...opts,
371
+ allowHTTP1: opts.allowHTTP1 !== false, // default true for graceful fallback
372
+ };
373
+ delete h2Opts.http2;
374
+ server = http2.createSecureServer(h2Opts, this.handler);
375
+ log.info('starting HTTP/2 (TLS) server on port %d', port);
376
+ }
377
+ else if (isH2)
378
+ {
379
+ // HTTP/2 cleartext (h2c) — no TLS, for internal services
380
+ const h2Opts = opts ? { ...opts } : {};
381
+ delete h2Opts.http2;
382
+ server = http2.createServer(h2Opts, this.handler);
383
+ log.info('starting HTTP/2 (h2c) server on port %d', port);
384
+ }
385
+ else if (hasTLS)
386
+ {
387
+ server = https.createServer(opts, this.handler);
388
+ log.info('starting HTTPS server on port %d', port);
389
+ }
390
+ else
391
+ {
392
+ server = http.createServer(this.handler);
393
+ log.info('starting HTTP server on port %d', port);
394
+ }
395
+
396
+ this._server = server;
397
+
398
+ // Intercept HTTP/2 streams for gRPC before the normal request handler
399
+ if (isH2 && this._grpcRegistry)
400
+ {
401
+ server.on('stream', (stream, headers) =>
402
+ {
403
+ if (this._grpcRegistry.handleStream(stream, headers))
404
+ return; // handled as gRPC
405
+ // Otherwise fall through — the compat handler fires the normal request event
406
+ });
407
+ }
408
+
409
+ // Always attach WebSocket upgrade handling so ws() works
410
+ // regardless of registration order (before or after listen).
411
+ server.on('upgrade', (req, socket, head) =>
412
+ {
413
+ if (this._wsHandlers.size > 0)
414
+ handleUpgrade(req, socket, head, this._wsHandlers);
415
+ else
416
+ socket.destroy();
417
+ });
418
+
419
+ // Install graceful shutdown signal handlers
420
+ this._lifecycle.installSignalHandlers();
421
+
422
+ return server.listen(port, cb);
423
+ }
424
+
425
+ /**
426
+ * Gracefully close the server, stopping new connections.
427
+ *
428
+ * @param {Function} [cb] - Callback invoked once the server has closed.
429
+ */
430
+ close(cb)
431
+ {
432
+ if (this._server) this._server.close(cb);
433
+ }
434
+
435
+ /**
436
+ * Perform a full graceful shutdown.
437
+ * Stops accepting new connections, drains in-flight requests, closes
438
+ * WebSocket, SSE, and gRPC connections, and shuts down registered databases.
439
+ *
440
+ * @param {object} [opts] - Shutdown options.
441
+ * @param {number} [opts.timeout] - Maximum ms to wait for in-flight requests (default 30000).
442
+ * @returns {Promise<void>} Resolves when shutdown is complete.
443
+ *
444
+ * @example
445
+ * await app.shutdown();
446
+ * // or with custom timeout
447
+ * await app.shutdown({ timeout: 5000 });
448
+ */
449
+ shutdown(opts)
450
+ {
451
+ if (!this._shutdownPromise)
452
+ {
453
+ this._shutdownPromise = this._lifecycle.shutdown(opts);
454
+ }
455
+ return this._shutdownPromise;
456
+ }
457
+
458
+ // -- Lifecycle Events ------------------------------
459
+
460
+ /**
461
+ * Register a lifecycle event listener.
462
+ *
463
+ * Supported events:
464
+ * - `'beforeShutdown'` — fires before shutdown begins (flush caches, finish writes)
465
+ * - `'shutdown'` — fires after shutdown is complete
466
+ *
467
+ * @param {'beforeShutdown'|'shutdown'} event - Lifecycle event name.
468
+ * @param {Function} fn - Async or sync callback.
469
+ * @returns {App} `this` for chaining.
470
+ *
471
+ * @example
472
+ * app.on('beforeShutdown', async () => {
473
+ * await saveMetrics();
474
+ * });
475
+ *
476
+ * app.on('shutdown', () => {
477
+ * console.log('goodbye');
478
+ * });
479
+ */
480
+ on(event, fn)
481
+ {
482
+ this._lifecycle.on(event, fn);
483
+ return this;
484
+ }
485
+
486
+ /**
487
+ * Remove a lifecycle event listener.
488
+ *
489
+ * @param {'beforeShutdown'|'shutdown'} event - Event name.
490
+ * @param {Function} fn - Callback to remove.
491
+ * @returns {App} `this` for chaining.
492
+ */
493
+ off(event, fn)
494
+ {
495
+ this._lifecycle.off(event, fn);
496
+ return this;
497
+ }
498
+
499
+ // -- Lifecycle Resource Registration ---------------
500
+
501
+ /**
502
+ * Register a WebSocket pool for graceful shutdown. All connections
503
+ * in the pool are closed with code `1001` when the server shuts down.
504
+ *
505
+ * @param {import('./ws/room')} pool - WebSocket pool instance.
506
+ * @returns {App} `this` for chaining.
507
+ *
508
+ * @example
509
+ * const pool = new WebSocketPool();
510
+ * app.registerPool(pool);
511
+ */
512
+ registerPool(pool)
513
+ {
514
+ this._lifecycle.registerPool(pool);
515
+ return this;
516
+ }
517
+
518
+ /**
519
+ * Unregister a WebSocket pool from lifecycle management.
520
+ *
521
+ * @param {import('./ws/room')} pool - WebSocket pool instance.
522
+ * @returns {App} `this` for chaining.
523
+ */
524
+ unregisterPool(pool)
525
+ {
526
+ this._lifecycle.unregisterPool(pool);
527
+ return this;
528
+ }
529
+
530
+ /**
531
+ * Track an SSE stream for graceful shutdown. The stream
532
+ * is automatically untracked when it closes.
533
+ *
534
+ * @param {import('./sse/stream')} stream - SSE stream instance.
535
+ * @returns {App} `this` for chaining.
536
+ */
537
+ trackSSE(stream)
538
+ {
539
+ this._lifecycle.trackSSE(stream);
540
+ return this;
541
+ }
542
+
543
+ /**
544
+ * Register an ORM Database instance for graceful shutdown.
545
+ * The database connection is closed during shutdown.
546
+ *
547
+ * @param {import('./orm')} db - Database instance.
548
+ * @returns {App} `this` for chaining.
549
+ *
550
+ * @example
551
+ * const db = new Database({ adapter: 'memory' });
552
+ * app.registerDatabase(db);
553
+ */
554
+ registerDatabase(db)
555
+ {
556
+ this._lifecycle.registerDatabase(db);
557
+ return this;
558
+ }
559
+
560
+ /**
561
+ * Unregister an ORM Database instance from lifecycle management.
562
+ *
563
+ * @param {import('./orm')} db - Database instance.
564
+ * @returns {App} `this` for chaining.
565
+ */
566
+ unregisterDatabase(db)
567
+ {
568
+ this._lifecycle.unregisterDatabase(db);
569
+ return this;
570
+ }
571
+
572
+ /**
573
+ * Configure the shutdown timeout—the maximum time (ms) to wait for
574
+ * in-flight requests to finish before forcefully terminating them.
575
+ *
576
+ * @param {number} ms - Timeout in milliseconds.
577
+ * @returns {App} `this` for chaining.
578
+ *
579
+ * @example
580
+ * app.shutdownTimeout(10000); // 10s
581
+ */
582
+ shutdownTimeout(ms)
583
+ {
584
+ this._lifecycle._shutdownTimeout = ms;
585
+ return this;
586
+ }
587
+
588
+ /**
589
+ * Current lifecycle state.
590
+ *
591
+ * @type {'running'|'draining'|'closed'}
592
+ */
593
+ get lifecycleState()
594
+ {
595
+ return this._lifecycle.state;
596
+ }
597
+
598
+ // -- Observability ---------------------------------
599
+
600
+ /**
601
+ * Register a liveness health check endpoint.
602
+ * Returns `200` when healthy, `503` during shutdown.
603
+ *
604
+ * @param {string} [path='/healthz'] - Endpoint path.
605
+ * @param {Object<string, Function>} [checks] - Named health check functions.
606
+ * @returns {App} `this` for chaining.
607
+ *
608
+ * @example
609
+ * app.health(); // GET /healthz
610
+ * app.health('/alive'); // GET /alive
611
+ * app.health('/healthz', { memory: memoryCheck() });
612
+ */
613
+ health(path, checks)
614
+ {
615
+ if (typeof path === 'object') { checks = path; path = '/healthz'; }
616
+ if (!path) path = '/healthz';
617
+ this.get(path, healthCheck({ checks: checks || {} }));
618
+ return this;
619
+ }
620
+
621
+ /**
622
+ * Register a readiness health check endpoint.
623
+ * Returns `200` when all checks pass, `503` otherwise.
624
+ *
625
+ * @param {string} [path='/readyz'] - Endpoint path.
626
+ * @param {Object<string, Function>} [checks] - Named readiness check functions.
627
+ * @returns {App} `this` for chaining.
628
+ *
629
+ * @example
630
+ * app.ready('/readyz', {
631
+ * database: () => db.ping(),
632
+ * cache: () => ({ healthy: redis.isConnected }),
633
+ * });
634
+ */
635
+ ready(path, checks)
636
+ {
637
+ if (typeof path === 'object') { checks = path; path = '/readyz'; }
638
+ if (!path) path = '/readyz';
639
+ this.get(path, healthCheck({ checks: checks || {} }));
640
+ return this;
641
+ }
642
+
643
+ /**
644
+ * Register a custom health check.
645
+ *
646
+ * @param {string} name - Check name.
647
+ * @param {Function} fn - Check function `() => { healthy, details }`.
648
+ * @returns {App} `this` for chaining.
649
+ *
650
+ * @example
651
+ * app.addHealthCheck('redis', async () => {
652
+ * await redis.ping();
653
+ * return { healthy: true };
654
+ * });
655
+ */
656
+ addHealthCheck(name, fn)
657
+ {
658
+ if (!this._healthChecks) this._healthChecks = {};
659
+ this._healthChecks[name] = fn;
660
+ return this;
661
+ }
662
+
663
+ /**
664
+ * Get the application metrics registry. Lazily created on first access.
665
+ * Returns a `MetricsRegistry` instance for registering custom metrics.
666
+ *
667
+ * @returns {import('./observe/metrics').MetricsRegistry} The metrics registry.
668
+ *
669
+ * @example
670
+ * const counter = app.metrics().counter({
671
+ * name: 'custom_events_total',
672
+ * help: 'Custom events',
673
+ * });
674
+ * counter.inc();
675
+ */
676
+ metrics()
677
+ {
678
+ if (!this._metricsRegistry)
679
+ {
680
+ this._metricsRegistry = new MetricsRegistry();
681
+ }
682
+ return this._metricsRegistry;
683
+ }
684
+
685
+ /**
686
+ * Mount a Prometheus metrics endpoint.
687
+ * Pair with `metricsMiddleware()` to auto-instrument all HTTP traffic.
688
+ *
689
+ * @param {string} [path='/metrics'] - Endpoint path.
690
+ * @param {object} [opts] - Options.
691
+ * @param {import('./observe/metrics').MetricsRegistry} [opts.registry] - Registry. Uses `app.metrics()` if not provided.
692
+ * @returns {App} `this` for chaining.
693
+ *
694
+ * @example | Full Observability Setup
695
+ * app.use(metricsMiddleware({ registry: app.metrics() }));
696
+ * app.metricsEndpoint(); // GET /metrics
697
+ * app.health(); // GET /healthz
698
+ * app.ready(); // GET /readyz
699
+ *
700
+ * @example yaml | prometheus.yml
701
+ * scrape_configs:
702
+ * - job_name: 'my-app'
703
+ * scrape_interval: 5s
704
+ * static_configs:
705
+ * - targets: ['localhost:3000']
706
+ *
707
+ * @example shell | Start Prometheus
708
+ * $ docker run -d -p 9090:9090 -v ./prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus
709
+ *
710
+ * @example | Custom Path
711
+ */
712
+ metricsEndpoint(path, opts = {})
713
+ {
714
+ if (typeof path === 'object') { opts = path; path = '/metrics'; }
715
+ if (!path) path = '/metrics';
716
+ const registry = opts.registry || this.metrics();
717
+ this.get(path, metricsEndpoint(registry));
718
+ return this;
719
+ }
720
+
721
+ // -- WebSocket Support -----------------------------
722
+
723
+ /**
724
+ * Register a WebSocket upgrade handler for a path.
725
+ *
726
+ * The handler receives `(ws, req)` where `ws` is a `WebSocketConnection`
727
+ * instance with methods like `send()`, `sendJSON()`, `on()`, and `close()`.
728
+ *
729
+ * @param {string} path - URL path to listen for upgrade requests.
730
+ * @param {object|Function} [opts] - Options object, or the handler function directly.
731
+ * @param {number} [opts.maxPayload=1048576] - Maximum incoming frame size in bytes (default 1 MB).
732
+ * @param {number} [opts.pingInterval=30000] - Auto-ping interval in ms. Set `0` to disable.
733
+ * @param {Function} [opts.verifyClient] - `(req) => boolean` — return false to reject the upgrade.
734
+ * @param {Function} handler - `(ws, req) => void`.
735
+ *
736
+ * @example | Simple
737
+ * app.ws('/chat', (ws, req) => {
738
+ * ws.on('message', data => ws.send('echo: ' + data));
739
+ * });
740
+ *
741
+ * @example | With Options
742
+ * app.ws('/feed', { maxPayload: 64 * 1024, pingInterval: 15000 }, (ws, req) => {
743
+ * console.log('client', ws.id, 'from', ws.ip);
744
+ * ws.sendJSON({ hello: 'world' });
745
+ * });
746
+ */
747
+ ws(path, opts, handler)
748
+ {
749
+ // Normalise arguments: ws(path, handler) or ws(path, opts, handler)
750
+ if (typeof opts === 'function') { handler = opts; opts = {}; }
751
+ if (!opts) opts = {};
752
+
753
+ this._wsHandlers.set(path, { handler, opts });
754
+ }
755
+
756
+ // -- Route Introspection ---------------------------
757
+
758
+ /**
759
+ * Return a flat list of all registered routes across the router tree,
760
+ * including mounted sub-routers. Useful for debugging, auto-generated
761
+ * docs, or CLI tooling.
762
+ *
763
+ * @returns {{ method: string, path: string }[]} Flat array of route entries including WebSocket handlers.
764
+ *
765
+ * @example
766
+ * app.routes().forEach(r => console.log(r.method, r.path));
767
+ * // GET /users
768
+ * // POST /users
769
+ * // GET /api/v1/items/:id
770
+ */
771
+ routes()
772
+ {
773
+ const list = this.router.inspect();
774
+
775
+ /* Include WebSocket upgrade handlers */
776
+ for (const [wsPath, { opts }] of this._wsHandlers)
777
+ {
778
+ const entry = { method: 'WS', path: wsPath };
779
+ if (opts && opts.maxPayload !== undefined) entry.maxPayload = opts.maxPayload;
780
+ if (opts && opts.pingInterval !== undefined) entry.pingInterval = opts.pingInterval;
781
+ list.push(entry);
782
+ }
783
+
784
+ /* Include gRPC routes */
785
+ if (this._grpcRegistry)
786
+ list.push(...this._grpcRegistry.routes());
787
+
788
+ return list;
789
+ }
790
+
791
+ // -- Route Registration ----------------------------
792
+
793
+ /**
794
+ * Extract an options object from the head of the handlers array when
795
+ * the first argument is a plain object (not a function).
796
+ * @private
797
+ */
798
+ _extractOpts(fns)
799
+ {
800
+ let opts = {};
801
+ if (fns.length > 0 && typeof fns[0] === 'object' && typeof fns[0] !== 'function')
802
+ {
803
+ opts = fns.shift();
804
+ }
805
+ return opts;
806
+ }
807
+
808
+ /**
809
+ * Register one or more handler functions for a specific HTTP method and path.
810
+ *
811
+ * @param {string} method - HTTP method (GET, POST, etc.) or 'ALL'.
812
+ * @param {string} path - Route pattern (e.g. '/users/:id').
813
+ * @param {...Function|object} fns - Optional options object `{ secure }` followed by handler functions.
814
+ */
815
+ route(method, path, ...fns) { const o = this._extractOpts(fns); this.router.add(method, path, fns, o); }
816
+
817
+ /**
818
+ * @see App#route — shortcut for GET requests.
819
+ * @param {string} path - Route pattern.
820
+ * @param {...Function} fns - Handler functions.
821
+ * @returns {App} `this` for chaining.
822
+ */
823
+ get(path, ...fns) { if (arguments.length === 1 && typeof path === 'string' && fns.length === 0) return this.set(path); this.route('GET', path, ...fns); return this; }
824
+ /**
825
+ * @see App#route — shortcut for POST requests.
826
+ * @param {string} path - Route pattern.
827
+ * @param {...Function} fns - Handler functions.
828
+ * @returns {App} `this` for chaining.
829
+ */
830
+ post(path, ...fns) { this.route('POST', path, ...fns); return this; }
831
+ /**
832
+ * @see App#route — shortcut for PUT requests.
833
+ * @param {string} path - Route pattern.
834
+ * @param {...Function} fns - Handler functions.
835
+ * @returns {App} `this` for chaining.
836
+ */
837
+ put(path, ...fns) { this.route('PUT', path, ...fns); return this; }
838
+ /**
839
+ * @see App#route — shortcut for DELETE requests.
840
+ * @param {string} path - Route pattern.
841
+ * @param {...Function} fns - Handler functions.
842
+ * @returns {App} `this` for chaining.
843
+ */
844
+ delete(path, ...fns) { this.route('DELETE', path, ...fns); return this; }
845
+ /**
846
+ * @see App#route — shortcut for PATCH requests.
847
+ * @param {string} path - Route pattern.
848
+ * @param {...Function} fns - Handler functions.
849
+ * @returns {App} `this` for chaining.
850
+ */
851
+ patch(path, ...fns) { this.route('PATCH', path, ...fns); return this; }
852
+ /**
853
+ * @see App#route — shortcut for OPTIONS requests.
854
+ * @param {string} path - Route pattern.
855
+ * @param {...Function} fns - Handler functions.
856
+ * @returns {App} `this` for chaining.
857
+ */
858
+ options(path, ...fns) { this.route('OPTIONS', path, ...fns); return this; }
859
+ /**
860
+ * @see App#route — shortcut for HEAD requests.
861
+ * @param {string} path - Route pattern.
862
+ * @param {...Function} fns - Handler functions.
863
+ * @returns {App} `this` for chaining.
864
+ */
865
+ head(path, ...fns) { this.route('HEAD', path, ...fns); return this; }
866
+ /**
867
+ * @see App#route — matches every HTTP method.
868
+ * @param {string} path - Route pattern.
869
+ * @param {...Function} fns - Handler functions.
870
+ * @returns {App} `this` for chaining.
871
+ */
872
+ all(path, ...fns) { this.route('ALL', path, ...fns); return this; }
873
+
874
+ /**
875
+ * Chainable route builder — register multiple methods on the same path.
876
+ *
877
+ * @param {string} path - Route pattern.
878
+ * @returns {object} Chain object with HTTP verb methods.
879
+ *
880
+ * @example
881
+ * app.chain('/users')
882
+ * .get((req, res) => res.json(users))
883
+ * .post((req, res) => res.json({ created: true }));
884
+ */
885
+ chain(path) { return this.router.route(path); }
886
+
887
+ /**
888
+ * Define a route group with shared middleware prefix.
889
+ * All routes registered inside the callback share the given path prefix
890
+ * and middleware stack.
891
+ *
892
+ * @param {string} prefix - URL prefix for the group.
893
+ * @param {...Function} middleware - Shared middleware, last argument is the callback.
894
+ * @returns {App} `this` for chaining.
895
+ *
896
+ * @example
897
+ * app.group('/api/v1', authMiddleware, (router) => {
898
+ * router.get('/users', listUsers);
899
+ * router.post('/users', createUser);
900
+ * router.get('/users/:id', getUser);
901
+ * });
902
+ */
903
+ group(prefix, ...args)
904
+ {
905
+ const cb = args.pop();
906
+ const middlewareStack = args;
907
+ const router = new Router();
908
+ cb(router);
909
+ if (middlewareStack.length > 0)
910
+ {
911
+ this.middlewares.push((req, res, next) =>
912
+ {
913
+ const urlPath = req.url.split('?')[0];
914
+ const cleanPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
915
+ if (urlPath === cleanPrefix || urlPath.startsWith(cleanPrefix + '/'))
916
+ {
917
+ let i = 0;
918
+ const runMw = () =>
919
+ {
920
+ if (i < middlewareStack.length)
921
+ {
922
+ const mw = middlewareStack[i++];
923
+ try
924
+ {
925
+ const result = mw(req, res, runMw);
926
+ if (result && typeof result.catch === 'function') result.catch(next);
927
+ }
928
+ catch (e) { next(e); }
929
+ }
930
+ else { next(); }
931
+ };
932
+ runMw();
933
+ }
934
+ else { next(); }
935
+ });
936
+ }
937
+ this.router.use(prefix, router);
938
+ return this;
939
+ }
940
+
941
+ // -- gRPC Support ----------------------------------
942
+
943
+ /**
944
+ * Register a gRPC service handler.
945
+ *
946
+ * Accepts a parsed proto schema, a service name, and a map of method handlers.
947
+ * When the server is HTTP/2, incoming `application/grpc` requests are routed
948
+ * to the matching service method automatically.
949
+ *
950
+ * @param {object} schema - Parsed proto schema from `parseProto()` or `parseProtoFile()`.
951
+ * @param {string} serviceName - Service name as defined in the proto file.
952
+ * @param {Object<string, Function>} handlers - Map of method names → handler functions.
953
+ * @param {object} [opts] - Service options.
954
+ * @param {Function[]} [opts.interceptors] - Per-service interceptors `(call, next) => void`.
955
+ * @param {number} [opts.maxMessageSize] - Max incoming message size in bytes.
956
+ * @param {boolean} [opts.compress=false] - Whether to compress outgoing messages.
957
+ * @returns {App} `this` for chaining.
958
+ *
959
+ * @example | Unary RPC
960
+ * const schema = parseProto(fs.readFileSync('hello.proto', 'utf8'));
961
+ * app.grpc(schema, 'Greeter', {
962
+ * SayHello(call) { return { message: 'Hello ' + call.request.name }; },
963
+ * });
964
+ * app.listen(50051, { http2: true });
965
+ *
966
+ * @example | Server streaming with interceptors
967
+ * app.grpc(schema, 'DataService', {
968
+ * StreamData(call) {
969
+ * for (let i = 0; i < 100; i++) call.write({ seq: i });
970
+ * call.end();
971
+ * },
972
+ * }, { interceptors: [authInterceptor] });
973
+ */
974
+ grpc(schema, serviceName, handlers, opts)
975
+ {
976
+ if (!this._grpcRegistry)
977
+ {
978
+ this._grpcRegistry = new GrpcServiceRegistry();
979
+ this._lifecycle.registerGrpc(this._grpcRegistry);
980
+ }
981
+ this._grpcRegistry.addService(schema, serviceName, handlers, opts);
982
+ return this;
983
+ }
984
+
985
+ /**
986
+ * Add a global gRPC interceptor that runs before every gRPC call.
987
+ *
988
+ * @param {Function} fn - Interceptor `(call, next) => void`.
989
+ * @returns {App} `this` for chaining.
990
+ *
991
+ * @example
992
+ * app.grpcInterceptor(async (call, next) => {
993
+ * console.log('gRPC call:', call.method.name);
994
+ * await next();
995
+ * });
996
+ */
997
+ grpcInterceptor(fn)
998
+ {
999
+ if (!this._grpcRegistry)
1000
+ {
1001
+ this._grpcRegistry = new GrpcServiceRegistry();
1002
+ this._lifecycle.registerGrpc(this._grpcRegistry);
1003
+ }
1004
+ this._grpcRegistry.addInterceptor(fn);
1005
+ return this;
1006
+ }
1007
+
1008
+ /**
1009
+ * Enable the gRPC Health Checking Protocol (`grpc.health.v1.Health`).
1010
+ * Registers the `Check` (unary) and `Watch` (server-stream) RPCs
1011
+ * required by Kubernetes, Envoy, and standard gRPC health probes.
1012
+ *
1013
+ * Call `app.setServiceStatus(name, status)` to update individual services.
1014
+ * Overall server health defaults to `SERVING`.
1015
+ *
1016
+ * @returns {App} `this` for chaining.
1017
+ *
1018
+ * @example
1019
+ * app.grpcHealth();
1020
+ * app.listen(50051, { http2: true });
1021
+ */
1022
+ grpcHealth()
1023
+ {
1024
+ if (this._healthService) return this;
1025
+
1026
+ const { HealthService } = require('./grpc/health');
1027
+ this._healthService = new HealthService();
1028
+
1029
+ const schema = this._healthService.getSchema();
1030
+ const handlers = this._healthService.getHandlers();
1031
+ this.grpc(schema, 'Health', handlers);
1032
+
1033
+ // Set all services to NOT_SERVING during graceful shutdown
1034
+ this._lifecycle.on('beforeShutdown', () =>
1035
+ {
1036
+ this._healthService.setAllNotServing();
1037
+ });
1038
+
1039
+ log.info('gRPC health check service enabled');
1040
+ return this;
1041
+ }
1042
+
1043
+ /**
1044
+ * Set the health status of a gRPC service.
1045
+ * Requires `grpcHealth()` to have been called first.
1046
+ *
1047
+ * @param {string} serviceName - Service name (empty string for overall server health).
1048
+ * @param {string|number} status - `'SERVING'`, `'NOT_SERVING'`, `'UNKNOWN'`, or numeric value.
1049
+ * @returns {App} `this` for chaining.
1050
+ *
1051
+ * @example
1052
+ * app.setServiceStatus('myapp.UserService', 'SERVING');
1053
+ * app.setServiceStatus('myapp.OrderService', 'NOT_SERVING');
1054
+ */
1055
+ setServiceStatus(serviceName, status)
1056
+ {
1057
+ if (!this._healthService)
1058
+ throw new Error('Call app.grpcHealth() before setServiceStatus()');
1059
+ this._healthService.setStatus(serviceName, status);
1060
+ return this;
1061
+ }
1062
+
1063
+ /**
1064
+ * Enable gRPC Server Reflection (`grpc.reflection.v1.ServerReflection`).
1065
+ * Allows `grpcurl`, `grpcui`, Postman, and other tools to introspect services.
1066
+ * Disabled in production by default unless `opts.production` is `true`.
1067
+ *
1068
+ * Must be called AFTER all `app.grpc()` registrations so that all schemas
1069
+ * are available for reflection.
1070
+ *
1071
+ * @param {object} [opts] - Options.
1072
+ * @param {boolean} [opts.production=false] - Enable in production environments.
1073
+ * @returns {App} `this` for chaining.
1074
+ *
1075
+ * @example
1076
+ * app.grpc(schema, 'Greeter', handlers);
1077
+ * app.grpcReflection();
1078
+ */
1079
+ grpcReflection(opts = {})
1080
+ {
1081
+ if (this._reflectionService) return this;
1082
+
1083
+ if (!opts.production && process.env.NODE_ENV === 'production')
1084
+ {
1085
+ log.warn('gRPC reflection disabled in production (set { production: true } to override)');
1086
+ return this;
1087
+ }
1088
+
1089
+ const { ReflectionService } = require('./grpc/reflection');
1090
+ this._reflectionService = new ReflectionService(opts);
1091
+
1092
+ // Register all already-registered schemas
1093
+ if (this._grpcRegistry)
1094
+ {
1095
+ for (const [path, route] of this._grpcRegistry._routes)
1096
+ {
1097
+ if (route.schema && !route.schema._reflectionRegistered)
1098
+ {
1099
+ this._reflectionService.addSchema(route.schema);
1100
+ route.schema._reflectionRegistered = true;
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ const schema = this._reflectionService.getSchema();
1106
+ const handlers = this._reflectionService.getHandlers();
1107
+ this.grpc(schema, 'ServerReflection', handlers);
1108
+
1109
+ log.info('gRPC server reflection enabled');
1110
+ return this;
1111
+ }
1112
+
1113
+ // -- Authentication & Sessions ---------------------
1114
+
1115
+ /**
1116
+ * Mount JWT authentication middleware.
1117
+ * Shorthand for `app.use(jwt(opts))`.
1118
+ *
1119
+ * @param {object} opts - JWT options (see `jwt()` for full documentation).
1120
+ * @returns {App} `this` for chaining.
1121
+ *
1122
+ * @example
1123
+ * app.jwt({ secret: process.env.JWT_SECRET });
1124
+ * app.jwt({ jwksUri: 'https://auth.example.com/.well-known/jwks.json' });
1125
+ */
1126
+ jwtAuth(opts)
1127
+ {
1128
+ const { jwt } = require('./auth/jwt');
1129
+ this.use(jwt(opts));
1130
+ return this;
1131
+ }
1132
+
1133
+ /**
1134
+ * Mount session middleware.
1135
+ * Shorthand for `app.use(session(opts))`.
1136
+ *
1137
+ * @param {object} opts - Session options (see `session()` for full documentation).
1138
+ * @returns {App} `this` for chaining.
1139
+ *
1140
+ * @example
1141
+ * app.sessions({ secret: process.env.SESSION_SECRET });
1142
+ */
1143
+ sessions(opts)
1144
+ {
1145
+ const { session } = require('./auth/session');
1146
+ this.use(session(opts));
1147
+ return this;
1148
+ }
1149
+
1150
+ /**
1151
+ * Create an OAuth2 client bound to this app.
1152
+ * Returns the client — does NOT mount any middleware automatically.
1153
+ *
1154
+ * @param {object} opts - OAuth options (see `oauth()` for full documentation).
1155
+ * @returns {{ authorize: Function, callback: Function, refresh: Function, userInfo: Function }}
1156
+ *
1157
+ * @example
1158
+ * const github = app.oauth({
1159
+ * provider: 'github',
1160
+ * clientId: process.env.GITHUB_CLIENT_ID,
1161
+ * clientSecret: process.env.GITHUB_CLIENT_SECRET,
1162
+ * callbackUrl: '/auth/github/callback',
1163
+ * });
1164
+ */
1165
+ oauth(opts)
1166
+ {
1167
+ const { oauth } = require('./auth/oauth');
1168
+ return oauth(opts);
1169
+ }
1170
+ }
1171
+
1172
+ module.exports = App;