@zero-server/lifecycle 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,557 @@
1
+ /**
2
+ * @module lifecycle
3
+ * @description Graceful shutdown manager for zero-server applications.
4
+ * Tracks active connections, drains in-flight requests, closes
5
+ * WebSocket, SSE, and gRPC connections, and shuts down ORM
6
+ * databases before exiting.
7
+ *
8
+ * @example
9
+ * const app = createApp();
10
+ * app.listen(3000);
11
+ *
12
+ * // Automatic — SIGTERM/SIGINT handlers registered by listen()
13
+ * // Manual trigger:
14
+ * await app.shutdown();
15
+ */
16
+ const log = require('./debug')('zero:lifecycle');
17
+
18
+ /**
19
+ * Lifecycle states.
20
+ * @enum {string}
21
+ */
22
+ const LIFECYCLE_STATE = {
23
+ RUNNING: 'running',
24
+ DRAINING: 'draining',
25
+ CLOSED: 'closed',
26
+ };
27
+
28
+ // -- Lifecycle Manager -------------------------
29
+
30
+ /**
31
+ * Manages graceful shutdown for an App instance.
32
+ * Tracks active HTTP connections and coordinates shutdown
33
+ * across the server, WebSocket pools, SSE streams, gRPC services,
34
+ * and ORM databases.
35
+ */
36
+ class LifecycleManager
37
+ {
38
+ /**
39
+ * @constructor
40
+ * @param {import('./app')} app - The App instance to manage.
41
+ */
42
+ constructor(app)
43
+ {
44
+ /** @type {import('./app')} */
45
+ this._app = app;
46
+
47
+ /** @type {string} */
48
+ this.state = LIFECYCLE_STATE.RUNNING;
49
+
50
+ /** Active HTTP requests being processed. @type {Set<import('http').ServerResponse>} */
51
+ this._activeRequests = new Set();
52
+
53
+ /** Registered WebSocket pools for shutdown. @type {Set<import('./ws/room')>} */
54
+ this._wsPools = new Set();
55
+
56
+ /** Active SSE streams for shutdown. @type {Set<import('./sse/stream')>} */
57
+ this._sseStreams = new Set();
58
+
59
+ /** gRPC service registry for shutdown draining. @type {import('./grpc/server').GrpcServiceRegistry|null} */
60
+ this._grpcRegistry = null;
61
+
62
+ /** ORM Database instances for shutdown. @type {Set<import('./orm')>} */
63
+ this._databases = new Set();
64
+
65
+ /** Lifecycle event listeners. @type {Object<string, Function[]>} */
66
+ this._listeners = {};
67
+
68
+ /** Signal handler references for cleanup. @private */
69
+ this._signalHandlers = {};
70
+
71
+ /** Whether signal handlers have been installed. @private */
72
+ this._signalsInstalled = false;
73
+
74
+ /** @type {number} Default shutdown timeout in ms. */
75
+ this._shutdownTimeout = 30000;
76
+
77
+ /** Prevents duplicate shutdown calls. @private */
78
+ this._shutdownPromise = null;
79
+ }
80
+
81
+ // -- Event Emitter ---------------------------------
82
+
83
+ /**
84
+ * Register a lifecycle event listener.
85
+ *
86
+ * @param {'beforeShutdown'|'shutdown'|'close'} event - Event name.
87
+ * @param {Function} fn - Callback function.
88
+ * @returns {LifecycleManager} this
89
+ *
90
+ * @example
91
+ * app.on('beforeShutdown', async () => {
92
+ * await flushMetrics();
93
+ * });
94
+ *
95
+ * app.on('shutdown', () => {
96
+ * console.log('server shut down');
97
+ * });
98
+ */
99
+ on(event, fn)
100
+ {
101
+ if (!this._listeners[event]) this._listeners[event] = [];
102
+ this._listeners[event].push(fn);
103
+ return this;
104
+ }
105
+
106
+ /**
107
+ * Remove a lifecycle event listener.
108
+ *
109
+ * @param {'beforeShutdown'|'shutdown'|'close'} event - Event name.
110
+ * @param {Function} fn - Callback to remove.
111
+ * @returns {LifecycleManager} this
112
+ */
113
+ off(event, fn)
114
+ {
115
+ const list = this._listeners[event];
116
+ if (!list) return this;
117
+ this._listeners[event] = list.filter(f => f !== fn);
118
+ return this;
119
+ }
120
+
121
+ /**
122
+ * Emit a lifecycle event, calling all registered listeners.
123
+ * Awaits async listeners sequentially.
124
+ * @private
125
+ * @param {string} event - Event name.
126
+ */
127
+ async _emit(event)
128
+ {
129
+ const fns = this._listeners[event];
130
+ if (!fns || fns.length === 0) return;
131
+ for (const fn of fns.slice())
132
+ {
133
+ try { await fn(); }
134
+ catch (err) { log.error('lifecycle %s listener error: %s', event, err.message); }
135
+ }
136
+ }
137
+
138
+ // -- Connection Tracking ---------------------------
139
+
140
+ /**
141
+ * Track an active HTTP request. Called automatically by the App
142
+ * request handler when lifecycle management is enabled.
143
+ *
144
+ * @param {import('http').ServerResponse} res - The raw response object.
145
+ */
146
+ trackRequest(res)
147
+ {
148
+ this._activeRequests.add(res);
149
+ res.on('close', () => this._activeRequests.delete(res));
150
+ }
151
+
152
+ /**
153
+ * Number of currently active HTTP requests.
154
+ * @type {number}
155
+ */
156
+ get activeRequests()
157
+ {
158
+ return this._activeRequests.size;
159
+ }
160
+
161
+ /**
162
+ * Register a WebSocket pool for graceful shutdown.
163
+ * All connections in registered pools are closed with code `1001`
164
+ * during shutdown.
165
+ *
166
+ * @param {import('./ws/room')} pool - WebSocket pool instance.
167
+ * @returns {LifecycleManager} this
168
+ *
169
+ * @example
170
+ * const pool = new WebSocketPool();
171
+ * app.registerPool(pool);
172
+ */
173
+ registerPool(pool)
174
+ {
175
+ this._wsPools.add(pool);
176
+ return this;
177
+ }
178
+
179
+ /**
180
+ * Unregister a WebSocket pool.
181
+ *
182
+ * @param {import('./ws/room')} pool - WebSocket pool instance.
183
+ * @returns {LifecycleManager} this
184
+ */
185
+ unregisterPool(pool)
186
+ {
187
+ this._wsPools.delete(pool);
188
+ return this;
189
+ }
190
+
191
+ /**
192
+ * Track an active SSE stream for graceful shutdown.
193
+ *
194
+ * @param {import('./sse/stream')} stream - SSE stream instance.
195
+ * @returns {LifecycleManager} this
196
+ */
197
+ trackSSE(stream)
198
+ {
199
+ this._sseStreams.add(stream);
200
+ stream.on('close', () => this._sseStreams.delete(stream));
201
+ return this;
202
+ }
203
+
204
+ /**
205
+ * Register an ORM Database instance for graceful shutdown.
206
+ * The database connection is closed during shutdown.
207
+ *
208
+ * @param {import('./orm')} db - Database instance.
209
+ * @returns {LifecycleManager} this
210
+ *
211
+ * @example
212
+ * const db = new Database({ adapter: 'sqlite', file: ':memory:' });
213
+ * app.registerDatabase(db);
214
+ */
215
+ registerDatabase(db)
216
+ {
217
+ this._databases.add(db);
218
+ return this;
219
+ }
220
+
221
+ /**
222
+ * Register the gRPC service registry for graceful shutdown.
223
+ * Active gRPC calls are drained before the server closes.
224
+ *
225
+ * @param {import('./grpc/server').GrpcServiceRegistry} registry - gRPC registry.
226
+ * @returns {LifecycleManager} this
227
+ */
228
+ registerGrpc(registry)
229
+ {
230
+ this._grpcRegistry = registry;
231
+ return this;
232
+ }
233
+
234
+ /**
235
+ * Unregister an ORM Database instance.
236
+ *
237
+ * @param {import('./orm')} db - Database instance.
238
+ * @returns {LifecycleManager} this
239
+ */
240
+ unregisterDatabase(db)
241
+ {
242
+ this._databases.delete(db);
243
+ return this;
244
+ }
245
+
246
+ // -- Signal Handling -------------------------------
247
+
248
+ /**
249
+ * Install `SIGTERM` and `SIGINT` process signal handlers that trigger
250
+ * graceful shutdown. Called automatically by `app.listen()`.
251
+ * Safe to call multiple times — handlers are only installed once.
252
+ */
253
+ installSignalHandlers()
254
+ {
255
+ if (this._signalsInstalled) return;
256
+ this._signalsInstalled = true;
257
+
258
+ const handler = (signal) =>
259
+ {
260
+ log.info('received %s, starting graceful shutdown', signal);
261
+ this.shutdown().then(() =>
262
+ {
263
+ process.exit(0);
264
+ }).catch((err) =>
265
+ {
266
+ log.error('shutdown error: %s', err.message);
267
+ process.exit(1);
268
+ });
269
+ };
270
+
271
+ this._signalHandlers.SIGTERM = () => handler('SIGTERM');
272
+ this._signalHandlers.SIGINT = () => handler('SIGINT');
273
+
274
+ process.on('SIGTERM', this._signalHandlers.SIGTERM);
275
+ process.on('SIGINT', this._signalHandlers.SIGINT);
276
+ }
277
+
278
+ /**
279
+ * Remove previously installed signal handlers.
280
+ * Called automatically during shutdown cleanup.
281
+ */
282
+ removeSignalHandlers()
283
+ {
284
+ if (!this._signalsInstalled) return;
285
+ if (this._signalHandlers.SIGTERM)
286
+ {
287
+ process.removeListener('SIGTERM', this._signalHandlers.SIGTERM);
288
+ }
289
+ if (this._signalHandlers.SIGINT)
290
+ {
291
+ process.removeListener('SIGINT', this._signalHandlers.SIGINT);
292
+ }
293
+ this._signalHandlers = {};
294
+ this._signalsInstalled = false;
295
+ }
296
+
297
+ // -- Shutdown Sequence -----------------------------
298
+
299
+ /**
300
+ * Perform a full graceful shutdown.
301
+ *
302
+ * Shutdown sequence:
303
+ * 1. Emit `'beforeShutdown'` — run pre-shutdown hooks (flush metrics, etc.)
304
+ * 2. Stop accepting new connections (server.close)
305
+ * 3. Close all WebSocket connections with code `1001` (Going Away)
306
+ * 4. Close all SSE streams
307
+ * 5. Drain active gRPC calls
308
+ * 6. Wait for in-flight HTTP requests to complete (with timeout)
309
+ * 7. Close all registered ORM database connections
310
+ * 8. Emit `'shutdown'` — final cleanup complete
311
+ *
312
+ * If in-flight requests do not complete within the configured
313
+ * timeout (default 30s), they are forcefully terminated.
314
+ *
315
+ * @param {object} [opts] - Shutdown options.
316
+ * @param {number} [opts.timeout] - Maximum ms to wait for in-flight requests. Overrides the configured default.
317
+ * @returns {Promise<void>} Resolves when shutdown is complete.
318
+ *
319
+ * @example | With Default Timeout
320
+ * await app.shutdown();
321
+ *
322
+ * @example | With Custom Timeout
323
+ * await app.shutdown({ timeout: 5000 });
324
+ */
325
+ async shutdown(opts = {})
326
+ {
327
+ // Deduplicate concurrent shutdown calls
328
+ if (this._shutdownPromise) return this._shutdownPromise;
329
+ if (this.state === LIFECYCLE_STATE.CLOSED) return;
330
+
331
+ this._shutdownPromise = this._doShutdown(opts);
332
+ return this._shutdownPromise;
333
+ }
334
+
335
+ /**
336
+ * Internal shutdown implementation.
337
+ * @private
338
+ */
339
+ async _doShutdown(opts)
340
+ {
341
+ const timeout = opts.timeout !== undefined ? opts.timeout : this._shutdownTimeout;
342
+ log.info('graceful shutdown initiated (timeout=%dms)', timeout);
343
+
344
+ // 1. beforeShutdown hooks
345
+ this.state = LIFECYCLE_STATE.DRAINING;
346
+ await this._emit('beforeShutdown');
347
+
348
+ // 2. Stop accepting new connections (non-blocking — server.close
349
+ // only resolves after all existing connections end, so we don't
350
+ // await it here; it will resolve after the drain step finishes).
351
+ const serverClosePromise = this._closeServer();
352
+
353
+ // 3. Close WebSocket connections
354
+ this._closeWebSockets();
355
+
356
+ // 4. Close SSE streams
357
+ this._closeSSEStreams();
358
+
359
+ // 5. Drain active gRPC calls
360
+ await this._drainGrpc(timeout);
361
+
362
+ // 6. Drain in-flight requests (with timeout + force-close)
363
+ await this._drainRequests(timeout);
364
+
365
+ // 7. Wait for server to fully close (should be instant now that
366
+ // all connections are gone)
367
+ await serverClosePromise;
368
+
369
+ // 8. Close databases
370
+ await this._closeDatabases();
371
+
372
+ // 9. Cleanup signals
373
+ this.removeSignalHandlers();
374
+
375
+ this.state = LIFECYCLE_STATE.CLOSED;
376
+ log.info('graceful shutdown complete');
377
+
378
+ // 10. Final event
379
+ await this._emit('shutdown');
380
+ }
381
+
382
+ /**
383
+ * Stop the HTTP server from accepting new connections.
384
+ * @private
385
+ * @returns {Promise<void>}
386
+ */
387
+ _closeServer()
388
+ {
389
+ return new Promise((resolve) =>
390
+ {
391
+ const server = this._app._server;
392
+ if (!server) { resolve(); return; }
393
+
394
+ server.close((err) =>
395
+ {
396
+ if (err) log.warn('server close error: %s', err.message);
397
+ resolve();
398
+ });
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Close all WebSocket connections across registered pools.
404
+ * @private
405
+ */
406
+ _closeWebSockets()
407
+ {
408
+ let closed = 0;
409
+ for (const pool of this._wsPools)
410
+ {
411
+ closed += pool.size;
412
+ pool.closeAll(1001, 'Server shutdown');
413
+ }
414
+ if (closed > 0) log.info('closed %d WebSocket connections', closed);
415
+ }
416
+
417
+ /**
418
+ * Close all tracked SSE streams.
419
+ * @private
420
+ */
421
+ _closeSSEStreams()
422
+ {
423
+ let closed = 0;
424
+ for (const stream of this._sseStreams)
425
+ {
426
+ if (stream.connected)
427
+ {
428
+ stream.close();
429
+ closed++;
430
+ }
431
+ }
432
+ this._sseStreams.clear();
433
+ if (closed > 0) log.info('closed %d SSE streams', closed);
434
+ }
435
+
436
+ /**
437
+ * Drain active gRPC calls via the service registry.
438
+ * @private
439
+ * @param {number} timeout - Max wait time in ms.
440
+ * @returns {Promise<void>}
441
+ */
442
+ async _drainGrpc(timeout)
443
+ {
444
+ if (!this._grpcRegistry) return;
445
+ await this._grpcRegistry.drain(timeout);
446
+ }
447
+
448
+ /**
449
+ * Wait for all in-flight HTTP requests to complete, or force-close
450
+ * after the timeout.
451
+ * @private
452
+ * @param {number} timeout - Max wait time in ms.
453
+ * @returns {Promise<void>}
454
+ */
455
+ _drainRequests(timeout)
456
+ {
457
+ return new Promise((resolve) =>
458
+ {
459
+ if (this._activeRequests.size === 0)
460
+ {
461
+ log.debug('no active requests to drain');
462
+ resolve();
463
+ return;
464
+ }
465
+
466
+ log.info('draining %d active requests', this._activeRequests.size);
467
+
468
+ let resolved = false;
469
+ const done = () =>
470
+ {
471
+ if (resolved) return;
472
+ resolved = true;
473
+ clearTimeout(timer);
474
+ resolve();
475
+ };
476
+
477
+ // Check periodically if all requests have completed
478
+ const check = () =>
479
+ {
480
+ if (this._activeRequests.size === 0) done();
481
+ };
482
+
483
+ // Listen for request completions
484
+ const interval = setInterval(() =>
485
+ {
486
+ check();
487
+ }, 50);
488
+
489
+ // Force-close after timeout
490
+ const timer = setTimeout(() =>
491
+ {
492
+ clearInterval(interval);
493
+ if (this._activeRequests.size > 0)
494
+ {
495
+ log.warn('force-closing %d requests after %dms timeout', this._activeRequests.size, timeout);
496
+ for (const res of this._activeRequests)
497
+ {
498
+ try
499
+ {
500
+ if (!res.writableEnded) res.end();
501
+ if (res.socket && !res.socket.destroyed) res.socket.destroy();
502
+ }
503
+ catch (_) { /* socket may already be destroyed */ }
504
+ }
505
+ this._activeRequests.clear();
506
+ }
507
+ done();
508
+ }, timeout);
509
+
510
+ // Don't let timer/interval keep the process alive
511
+ if (timer.unref) timer.unref();
512
+ if (interval.unref) interval.unref();
513
+
514
+ // Immediate check in case requests finished already
515
+ check();
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Close all registered ORM database connections.
521
+ * @private
522
+ */
523
+ async _closeDatabases()
524
+ {
525
+ for (const db of this._databases)
526
+ {
527
+ try
528
+ {
529
+ if (typeof db.close === 'function') await db.close();
530
+ }
531
+ catch (err)
532
+ {
533
+ log.error('database close error: %s', err.message);
534
+ }
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Whether the server is currently draining (rejecting new requests).
540
+ * @type {boolean}
541
+ */
542
+ get isDraining()
543
+ {
544
+ return this.state === LIFECYCLE_STATE.DRAINING;
545
+ }
546
+
547
+ /**
548
+ * Whether the server has fully shut down.
549
+ * @type {boolean}
550
+ */
551
+ get isClosed()
552
+ {
553
+ return this.state === LIFECYCLE_STATE.CLOSED;
554
+ }
555
+ }
556
+
557
+ module.exports = { LifecycleManager, LIFECYCLE_STATE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zero-server/lifecycle",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Graceful shutdown manager and multi-worker clustering.",
5
5
  "keywords": [
6
6
  "zero-server",
@@ -20,6 +20,8 @@
20
20
  "./package.json": "./package.json"
21
21
  },
22
22
  "files": [
23
+ "lib",
24
+ "types",
23
25
  "index.js",
24
26
  "index.d.ts",
25
27
  "README.md",
@@ -42,7 +44,12 @@
42
44
  "access": "public"
43
45
  },
44
46
  "sideEffects": false,
45
- "dependencies": {
46
- "@zero-server/sdk": "0.9.1"
47
+ "peerDependencies": {
48
+ "@zero-server/sdk": ">=0.9.3"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@zero-server/sdk": {
52
+ "optional": true
53
+ }
47
54
  }
48
55
  }