@moreapp/common-nodejs 0.12.7 → 0.13.0

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.
@@ -3,9 +3,11 @@ import ExpressRequestTracker from "./ExpressRequestTracker";
3
3
  import ObservabilityServer from "./ObservabilityServer";
4
4
  export default class TerminationHandler {
5
5
  private readonly server;
6
- private readonly hs;
6
+ private readonly observabilityServer;
7
7
  private readonly requestTracker;
8
+ private readonly onTermination;
8
9
  private readonly sockets;
9
- constructor(server: Server, hs: ObservabilityServer, requestTracker: ExpressRequestTracker);
10
+ private isShuttingDown;
11
+ constructor(server: Server, observabilityServer: ObservabilityServer, requestTracker: ExpressRequestTracker, onTermination?: () => Promise<void>);
10
12
  shutdown(): void;
11
13
  }
@@ -10,13 +10,16 @@ const TERMINATION_SIGNALS = ["SIGTERM", "SIGHUP", "SIGINT"];
10
10
  const SHUTDOWN_TIMEOUT = 30_000;
11
11
  class TerminationHandler {
12
12
  server;
13
- hs;
13
+ observabilityServer;
14
14
  requestTracker;
15
+ onTermination;
15
16
  sockets = new Set();
16
- constructor(server, hs, requestTracker) {
17
+ isShuttingDown = false;
18
+ constructor(server, observabilityServer, requestTracker, onTermination = () => Promise.resolve()) {
17
19
  this.server = server;
18
- this.hs = hs;
20
+ this.observabilityServer = observabilityServer;
19
21
  this.requestTracker = requestTracker;
22
+ this.onTermination = onTermination;
20
23
  for (const signal of TERMINATION_SIGNALS) {
21
24
  process.on(signal, () => {
22
25
  logger_1.logger.info("Received termination signal", { signal });
@@ -27,21 +30,35 @@ class TerminationHandler {
27
30
  this.sockets.add(socket);
28
31
  socket.on("close", () => this.sockets.delete(socket));
29
32
  });
33
+ // Handles errors like `EADDRINUSE`
34
+ server.on("error", (err) => {
35
+ logger_1.logger.error("Server threw an unrecoverable error, shutting down", err);
36
+ this.shutdown();
37
+ });
30
38
  }
31
39
  shutdown() {
32
- this.hs.setReady(false);
40
+ if (this.isShuttingDown) {
41
+ return;
42
+ }
43
+ this.isShuttingDown = true;
44
+ this.observabilityServer.setReady(false);
33
45
  logger_1.logger.info("Stopping server");
34
46
  this.server.close();
35
47
  const interval = (0, node_timers_1.setInterval)(() => {
36
48
  const activeRequests = this.requestTracker.nrOfActiveRequests();
37
49
  if (activeRequests === 0) {
38
- (0, node_timers_1.clearInterval)(interval);
39
- // Destroy any remaining sockets
40
- this.sockets.forEach((socket) => socket.destroy());
41
- void Promise.all([this.hs.stop(), tracer_1.default.shutdown()]).finally(() => {
42
- logger_1.logger.info("Server stopped");
43
- process.exit(0);
44
- });
50
+ void (async () => {
51
+ (0, node_timers_1.clearInterval)(interval);
52
+ // Destroy any remaining sockets
53
+ this.sockets.forEach((socket) => socket.destroy());
54
+ await this.onTermination().catch((err) => logger_1.logger.error("Error occurred in termination handler", err));
55
+ void Promise.all([this.observabilityServer.stop(), tracer_1.default.shutdown()])
56
+ .catch((err) => logger_1.logger.error("Error occurred during shutdown", err))
57
+ .finally(() => {
58
+ logger_1.logger.info("Server stopped");
59
+ process.exit(0);
60
+ });
61
+ })();
45
62
  }
46
63
  else {
47
64
  logger_1.logger.info("Waiting for requests to finish", { activeRequests });
@@ -40,6 +40,7 @@ const vitest_1 = require("vitest");
40
40
  const node_events_1 = require("node:events");
41
41
  const timers = __importStar(require("node:timers"));
42
42
  const TerminationHandler_1 = __importDefault(require("../TerminationHandler"));
43
+ const logger_1 = require("../../logger");
43
44
  vitest_1.vi.mock("../../logger", () => ({
44
45
  logger: {
45
46
  info: vitest_1.vi.fn(),
@@ -115,12 +116,9 @@ vitest_1.vi.mock("node:timers", () => ({
115
116
  (0, vitest_1.expect)(server.close).toHaveBeenCalled();
116
117
  // First interval tick should see 0 active and exit
117
118
  intervalCb?.();
118
- // ensure hs.stop called
119
- (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
120
- // Wait for Promise.all and .finally() to execute
119
+ // Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
121
120
  await new Promise((resolve) => setImmediate(resolve));
122
- // Trigger log flush timeout (index 1, after the 30s timeout at index 0)
123
- timeoutCallbacks[1]?.();
121
+ (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
124
122
  (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
125
123
  });
126
124
  (0, vitest_1.test)("shutdown polls until requests finish then exits 0", async () => {
@@ -140,11 +138,9 @@ vitest_1.vi.mock("node:timers", () => ({
140
138
  (0, vitest_1.expect)(exitSpy).not.toHaveBeenCalled();
141
139
  // Second tick: now 0
142
140
  intervalCb?.();
143
- (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
144
- // Wait for Promise.all and .finally() to execute
141
+ // Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
145
142
  await new Promise((resolve) => setImmediate(resolve));
146
- // Trigger log flush timeout (index 1, after the 30s timeout at index 0)
147
- timeoutCallbacks[1]?.();
143
+ (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
148
144
  (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
149
145
  });
150
146
  (0, vitest_1.test)("forceful stop after 30s destroys sockets and exits 1", () => {
@@ -168,4 +164,71 @@ vitest_1.vi.mock("node:timers", () => ({
168
164
  (0, vitest_1.expect)(socket.destroy).toHaveBeenCalled();
169
165
  (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(1);
170
166
  });
167
+ (0, vitest_1.test)("invokes onTermination during graceful shutdown then exits 0", async () => {
168
+ const server = createFakeServer();
169
+ const hs = {
170
+ setReady: vitest_1.vi.fn(),
171
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
172
+ };
173
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
174
+ const onTermination = vitest_1.vi.fn(() => Promise.resolve());
175
+ const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
176
+ new TerminationHandler_1.default(server, hs, requestTracker, onTermination).shutdown();
177
+ // First interval tick sees 0 active and runs the graceful path
178
+ intervalCb?.();
179
+ // Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
180
+ await new Promise((resolve) => setImmediate(resolve));
181
+ (0, vitest_1.expect)(onTermination).toHaveBeenCalledTimes(1);
182
+ (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
183
+ });
184
+ (0, vitest_1.test)("catches and logs onTermination rejection but still exits 0", async () => {
185
+ const server = createFakeServer();
186
+ const hs = {
187
+ setReady: vitest_1.vi.fn(),
188
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
189
+ };
190
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
191
+ const onTermination = vitest_1.vi.fn(() => Promise.reject(new Error("boom")));
192
+ const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
193
+ new TerminationHandler_1.default(server, hs, requestTracker, onTermination).shutdown();
194
+ intervalCb?.();
195
+ await new Promise((resolve) => setImmediate(resolve));
196
+ (0, vitest_1.expect)(vitest_1.vi.mocked(logger_1.logger.error)).toHaveBeenCalledWith("Error occurred in termination handler", vitest_1.expect.any(Error));
197
+ // A failing hook must not divert to the forceful exit-1 path
198
+ (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
199
+ });
200
+ (0, vitest_1.test)("shuts down when the server emits an error", async () => {
201
+ const server = createFakeServer();
202
+ const hs = {
203
+ setReady: vitest_1.vi.fn(),
204
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
205
+ };
206
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
207
+ const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
208
+ new TerminationHandler_1.default(server, hs, requestTracker);
209
+ // e.g. EADDRINUSE surfaces as a server "error" event
210
+ server.emit("error", new Error("EADDRINUSE"));
211
+ (0, vitest_1.expect)(vitest_1.vi.mocked(logger_1.logger.error)).toHaveBeenCalledWith("Server threw an unrecoverable error, shutting down", vitest_1.expect.any(Error));
212
+ (0, vitest_1.expect)(hs.setReady).toHaveBeenCalledWith(false);
213
+ (0, vitest_1.expect)(server.close).toHaveBeenCalled();
214
+ intervalCb?.();
215
+ await new Promise((resolve) => setImmediate(resolve));
216
+ (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
217
+ });
218
+ (0, vitest_1.test)("ignores a second shutdown call", () => {
219
+ const server = createFakeServer();
220
+ const hs = {
221
+ setReady: vitest_1.vi.fn(),
222
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
223
+ };
224
+ // Never reaches 0 so the poll loop stays active between the two calls
225
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 5) };
226
+ vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
227
+ const th = new TerminationHandler_1.default(server, hs, requestTracker);
228
+ th.shutdown();
229
+ th.shutdown();
230
+ (0, vitest_1.expect)(hs.setReady).toHaveBeenCalledTimes(1);
231
+ (0, vitest_1.expect)(server.close).toHaveBeenCalledTimes(1);
232
+ (0, vitest_1.expect)(vitest_1.vi.mocked(timers.setInterval)).toHaveBeenCalledTimes(1);
233
+ });
171
234
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moreapp/common-nodejs",
3
- "version": "0.12.7",
3
+ "version": "0.13.0",
4
4
  "license": "UNLICENSED",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",