@moreapp/common-nodejs 0.12.7 → 0.13.1

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 });
@@ -66,6 +66,7 @@ vitest_1.vi.mock("../../logger", () => ({
66
66
  nrOfActiveRequests: vitest_1.vi.fn(() => 0),
67
67
  };
68
68
  const hs = await ObservabilityServer_1.default.create(requestTracker, { port: 0 });
69
+ // eslint-disable-next-line @typescript-eslint/unbound-method
69
70
  (0, vitest_1.expect)(fakeServer.listen).toHaveBeenCalled();
70
71
  // /live
71
72
  const resLive = createRes();
@@ -97,6 +98,7 @@ vitest_1.vi.mock("../../logger", () => ({
97
98
  capturedHandler({ url: "/unknown" }, res404);
98
99
  (0, vitest_1.expect)(res404.statusCode).toBe(404);
99
100
  await hs.stop();
101
+ // eslint-disable-next-line @typescript-eslint/unbound-method
100
102
  (0, vitest_1.expect)(fakeServer.close).toHaveBeenCalled();
101
103
  createServerSpy.mockRestore();
102
104
  });
@@ -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(),
@@ -112,15 +113,13 @@ vitest_1.vi.mock("node:timers", () => ({
112
113
  const th = new TerminationHandler_1.default(server, hs, requestTracker);
113
114
  th.shutdown();
114
115
  (0, vitest_1.expect)(hs.setReady).toHaveBeenCalledWith(false);
116
+ // eslint-disable-next-line @typescript-eslint/unbound-method
115
117
  (0, vitest_1.expect)(server.close).toHaveBeenCalled();
116
118
  // First interval tick should see 0 active and exit
117
119
  intervalCb?.();
118
- // ensure hs.stop called
119
- (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
120
- // Wait for Promise.all and .finally() to execute
120
+ // Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
121
121
  await new Promise((resolve) => setImmediate(resolve));
122
- // Trigger log flush timeout (index 1, after the 30s timeout at index 0)
123
- timeoutCallbacks[1]?.();
122
+ (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
124
123
  (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
125
124
  });
126
125
  (0, vitest_1.test)("shutdown polls until requests finish then exits 0", async () => {
@@ -140,11 +139,9 @@ vitest_1.vi.mock("node:timers", () => ({
140
139
  (0, vitest_1.expect)(exitSpy).not.toHaveBeenCalled();
141
140
  // Second tick: now 0
142
141
  intervalCb?.();
143
- (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
144
- // Wait for Promise.all and .finally() to execute
142
+ // Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
145
143
  await new Promise((resolve) => setImmediate(resolve));
146
- // Trigger log flush timeout (index 1, after the 30s timeout at index 0)
147
- timeoutCallbacks[1]?.();
144
+ (0, vitest_1.expect)(hs.stop).toHaveBeenCalled();
148
145
  (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
149
146
  });
150
147
  (0, vitest_1.test)("forceful stop after 30s destroys sockets and exits 1", () => {
@@ -162,10 +159,81 @@ vitest_1.vi.mock("node:timers", () => ({
162
159
  th.shutdown();
163
160
  // Before timeout callback, no exit
164
161
  (0, vitest_1.expect)(exitSpy).not.toHaveBeenCalled();
162
+ // eslint-disable-next-line @typescript-eslint/unbound-method
165
163
  (0, vitest_1.expect)(socket.destroy).not.toHaveBeenCalled();
166
164
  // Trigger shutdown timeout (index 0 is the 30s timeout)
167
165
  timeoutCallbacks[0]?.();
166
+ // eslint-disable-next-line @typescript-eslint/unbound-method
168
167
  (0, vitest_1.expect)(socket.destroy).toHaveBeenCalled();
169
168
  (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(1);
170
169
  });
170
+ (0, vitest_1.test)("invokes onTermination during graceful shutdown then exits 0", async () => {
171
+ const server = createFakeServer();
172
+ const hs = {
173
+ setReady: vitest_1.vi.fn(),
174
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
175
+ };
176
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
177
+ const onTermination = vitest_1.vi.fn(() => Promise.resolve());
178
+ const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
179
+ new TerminationHandler_1.default(server, hs, requestTracker, onTermination).shutdown();
180
+ // First interval tick sees 0 active and runs the graceful path
181
+ intervalCb?.();
182
+ // Wait for the async IIFE (await onTermination + Promise.all().finally()) to settle
183
+ await new Promise((resolve) => setImmediate(resolve));
184
+ (0, vitest_1.expect)(onTermination).toHaveBeenCalledTimes(1);
185
+ (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
186
+ });
187
+ (0, vitest_1.test)("catches and logs onTermination rejection but still exits 0", async () => {
188
+ const server = createFakeServer();
189
+ const hs = {
190
+ setReady: vitest_1.vi.fn(),
191
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
192
+ };
193
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
194
+ const onTermination = vitest_1.vi.fn(() => Promise.reject(new Error("boom")));
195
+ const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
196
+ new TerminationHandler_1.default(server, hs, requestTracker, onTermination).shutdown();
197
+ intervalCb?.();
198
+ await new Promise((resolve) => setImmediate(resolve));
199
+ (0, vitest_1.expect)(vitest_1.vi.mocked(logger_1.logger.error)).toHaveBeenCalledWith("Error occurred in termination handler", vitest_1.expect.any(Error));
200
+ // A failing hook must not divert to the forceful exit-1 path
201
+ (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
202
+ });
203
+ (0, vitest_1.test)("shuts down when the server emits an error", async () => {
204
+ const server = createFakeServer();
205
+ const hs = {
206
+ setReady: vitest_1.vi.fn(),
207
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
208
+ };
209
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 0) };
210
+ const exitSpy = vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
211
+ new TerminationHandler_1.default(server, hs, requestTracker);
212
+ // e.g. EADDRINUSE surfaces as a server "error" event
213
+ server.emit("error", new Error("EADDRINUSE"));
214
+ (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));
215
+ (0, vitest_1.expect)(hs.setReady).toHaveBeenCalledWith(false);
216
+ // eslint-disable-next-line @typescript-eslint/unbound-method
217
+ (0, vitest_1.expect)(server.close).toHaveBeenCalled();
218
+ intervalCb?.();
219
+ await new Promise((resolve) => setImmediate(resolve));
220
+ (0, vitest_1.expect)(exitSpy).toHaveBeenCalledWith(0);
221
+ });
222
+ (0, vitest_1.test)("ignores a second shutdown call", () => {
223
+ const server = createFakeServer();
224
+ const hs = {
225
+ setReady: vitest_1.vi.fn(),
226
+ stop: vitest_1.vi.fn(() => Promise.resolve()),
227
+ };
228
+ // Never reaches 0 so the poll loop stays active between the two calls
229
+ const requestTracker = { nrOfActiveRequests: vitest_1.vi.fn(() => 5) };
230
+ vitest_1.vi.spyOn(process, "exit").mockImplementation(() => undefined);
231
+ const th = new TerminationHandler_1.default(server, hs, requestTracker);
232
+ th.shutdown();
233
+ th.shutdown();
234
+ (0, vitest_1.expect)(hs.setReady).toHaveBeenCalledTimes(1);
235
+ // eslint-disable-next-line @typescript-eslint/unbound-method
236
+ (0, vitest_1.expect)(server.close).toHaveBeenCalledTimes(1);
237
+ (0, vitest_1.expect)(vitest_1.vi.mocked(timers.setInterval)).toHaveBeenCalledTimes(1);
238
+ });
171
239
  });
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.1",
4
4
  "license": "UNLICENSED",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -30,19 +30,19 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@opentelemetry/api": "1.9.1",
33
- "@opentelemetry/core": "2.8.0",
34
- "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0",
35
- "@opentelemetry/instrumentation": "0.219.0",
36
- "@opentelemetry/instrumentation-dns": "0.62.0",
37
- "@opentelemetry/instrumentation-express": "0.67.0",
38
- "@opentelemetry/instrumentation-http": "0.219.0",
39
- "@opentelemetry/instrumentation-winston": "0.63.0",
40
- "@opentelemetry/resources": "2.8.0",
41
- "@opentelemetry/sdk-node": "0.219.0",
42
- "@opentelemetry/sdk-trace-base": "2.8.0",
43
- "@opentelemetry/sdk-trace-node": "2.8.0",
44
- "@opentelemetry/semantic-conventions": "1.41.1",
45
- "axios": "1.18.1",
33
+ "@opentelemetry/core": "2.10.0",
34
+ "@opentelemetry/exporter-trace-otlp-grpc": "0.221.0",
35
+ "@opentelemetry/instrumentation": "0.221.0",
36
+ "@opentelemetry/instrumentation-dns": "0.64.0",
37
+ "@opentelemetry/instrumentation-express": "0.69.0",
38
+ "@opentelemetry/instrumentation-http": "0.221.0",
39
+ "@opentelemetry/instrumentation-winston": "0.65.0",
40
+ "@opentelemetry/resources": "2.10.0",
41
+ "@opentelemetry/sdk-node": "0.221.0",
42
+ "@opentelemetry/sdk-trace-base": "2.10.0",
43
+ "@opentelemetry/sdk-trace-node": "2.10.0",
44
+ "@opentelemetry/semantic-conventions": "1.43.0",
45
+ "axios": "1.19.0",
46
46
  "content-disposition-parser": "1.0.2",
47
47
  "date-and-time": "4.5.1",
48
48
  "express": "5.2.1",
@@ -53,24 +53,24 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/express": "5.0.6",
56
- "@types/lodash": "4.17.24",
57
- "@types/node": "24.13.2",
58
- "@typescript-eslint/eslint-plugin": "8.61.1",
59
- "@typescript-eslint/parser": "8.61.1",
60
- "@vitest/coverage-v8": "4.1.9",
61
- "@vitest/ui": "4.1.9",
62
- "eslint": "9.39.4",
56
+ "@types/lodash": "4.17.25",
57
+ "@types/node": "24.13.3",
58
+ "@typescript-eslint/eslint-plugin": "8.67.0",
59
+ "@typescript-eslint/parser": "8.67.0",
60
+ "@vitest/coverage-v8": "4.1.10",
61
+ "@vitest/ui": "4.1.10",
62
+ "eslint": "9.39.5",
63
63
  "eslint-config-prettier": "10.1.8",
64
64
  "eslint-plugin-prettier": "5.5.6",
65
65
  "husky": "9.1.7",
66
- "lint-staged": "17.0.8",
67
- "nock": "14.0.16",
68
- "prettier": "3.8.5",
66
+ "lint-staged": "17.3.0",
67
+ "nock": "14.0.17",
68
+ "prettier": "3.9.6",
69
69
  "ts-node": "10.9.2",
70
70
  "typescript": "6.0.3",
71
- "typescript-eslint": "8.61.1",
72
- "vitest": "4.1.9",
73
- "vitest-mock-extended": "4.0.0"
71
+ "typescript-eslint": "8.67.0",
72
+ "vitest": "4.1.10",
73
+ "vitest-mock-extended": "5.1.1"
74
74
  },
75
75
  "prettier": {
76
76
  "printWidth": 100,