@zero-server/sdk 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/LICENSE +21 -21
- package/README.md +460 -437
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +460 -460
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +136 -136
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +254 -254
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/lifecycle.js
CHANGED
|
@@ -1,557 +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 };
|
|
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 };
|