@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
|
|
6
|
+
private readonly observabilityServer;
|
|
7
7
|
private readonly requestTracker;
|
|
8
|
+
private readonly onTermination;
|
|
8
9
|
private readonly sockets;
|
|
9
|
-
|
|
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
|
-
|
|
13
|
+
observabilityServer;
|
|
14
14
|
requestTracker;
|
|
15
|
+
onTermination;
|
|
15
16
|
sockets = new Set();
|
|
16
|
-
|
|
17
|
+
isShuttingDown = false;
|
|
18
|
+
constructor(server, observabilityServer, requestTracker, onTermination = () => Promise.resolve()) {
|
|
17
19
|
this.server = server;
|
|
18
|
-
this.
|
|
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.
|
|
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
|
-
(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
logger_1.logger.
|
|
43
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@opentelemetry/exporter-trace-otlp-grpc": "0.
|
|
35
|
-
"@opentelemetry/instrumentation": "0.
|
|
36
|
-
"@opentelemetry/instrumentation-dns": "0.
|
|
37
|
-
"@opentelemetry/instrumentation-express": "0.
|
|
38
|
-
"@opentelemetry/instrumentation-http": "0.
|
|
39
|
-
"@opentelemetry/instrumentation-winston": "0.
|
|
40
|
-
"@opentelemetry/resources": "2.
|
|
41
|
-
"@opentelemetry/sdk-node": "0.
|
|
42
|
-
"@opentelemetry/sdk-trace-base": "2.
|
|
43
|
-
"@opentelemetry/sdk-trace-node": "2.
|
|
44
|
-
"@opentelemetry/semantic-conventions": "1.
|
|
45
|
-
"axios": "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.
|
|
57
|
-
"@types/node": "24.13.
|
|
58
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
59
|
-
"@typescript-eslint/parser": "8.
|
|
60
|
-
"@vitest/coverage-v8": "4.1.
|
|
61
|
-
"@vitest/ui": "4.1.
|
|
62
|
-
"eslint": "9.39.
|
|
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
|
|
67
|
-
"nock": "14.0.
|
|
68
|
-
"prettier": "3.
|
|
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.
|
|
72
|
-
"vitest": "4.1.
|
|
73
|
-
"vitest-mock-extended": "
|
|
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,
|