@moreapp/common-nodejs 0.8.1 → 0.9.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.
- package/README.md +1 -21
- package/dist/dateUtil.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/k8s/ExpressRequestTracker.d.ts +6 -0
- package/dist/k8s/ExpressRequestTracker.js +18 -0
- package/dist/k8s/ExpressRequestTracker.test.d.ts +1 -0
- package/dist/k8s/ExpressRequestTracker.test.js +53 -0
- package/dist/k8s/HealthServer.d.ts +17 -0
- package/dist/k8s/HealthServer.js +67 -0
- package/dist/k8s/HealthServer.test.d.ts +1 -0
- package/dist/k8s/HealthServer.test.js +94 -0
- package/dist/k8s/TerminationHandler.d.ts +12 -0
- package/dist/k8s/TerminationHandler.js +53 -0
- package/dist/k8s/TerminationHandler.test.d.ts +1 -0
- package/dist/k8s/TerminationHandler.test.js +157 -0
- package/dist/k8s/index.d.ts +4 -0
- package/dist/k8s/index.js +12 -0
- package/dist/testUtils.d.ts +2 -0
- package/dist/testUtils.js +13 -0
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -8,27 +8,7 @@ Run `yarn prepare` once to install Git hooks, doing lints and prettier formattin
|
|
|
8
8
|
|
|
9
9
|
## Project usage
|
|
10
10
|
|
|
11
|
-
Most code can be used as is
|
|
12
|
-
|
|
13
|
-
### Tracing
|
|
14
|
-
|
|
15
|
-
Tracing code should always be loaded (`import`/`require`) first, before any other libraries. This is because the
|
|
16
|
-
OpenTelemetry instrumentations monkey patch libraries to add tracing. Patching has to happen first, because already
|
|
17
|
-
loaded libraries will be cached by the module system.
|
|
18
|
-
|
|
19
|
-
The recommended way to do this, is to create a file `instrumentation.ts` with the following content (the extra
|
|
20
|
-
instrumentation is there as an example):
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
import { tracer } from "@moreapp/common-nodejs";
|
|
24
|
-
import { MongoDBInstrumentation } from "@opentelemetry/instrumentation-mongodb";
|
|
25
|
-
|
|
26
|
-
tracer("<SERVICE_NAME>", {
|
|
27
|
-
extraInstrumentations: [new MongoDBInstrumentation()],
|
|
28
|
-
});
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
And then use the `--require` [Node.js CLI option](https://nodejs.org/api/cli.html#-r---require-module) to load this file.
|
|
11
|
+
Most code can be used as is. See [service configuration](./docs/service-configuration.md) for code that needs more setup.
|
|
32
12
|
|
|
33
13
|
## Creating a new release
|
|
34
14
|
|
package/dist/dateUtil.js
CHANGED
|
@@ -22,9 +22,9 @@ function formatDate(dateString, format) {
|
|
|
22
22
|
exports.formatDate = formatDate;
|
|
23
23
|
function formatDateTime(dateTime, format) {
|
|
24
24
|
const date = typeof dateTime === "string"
|
|
25
|
-
? date_and_time_1.default.parse(dateTime, "YYYY-MM-DD HH:mm",
|
|
25
|
+
? date_and_time_1.default.parse(dateTime, "YYYY-MM-DD HH:mm", { timeZone: "UTC" })
|
|
26
26
|
: new Date(dateTime);
|
|
27
|
-
return date_and_time_1.default.format(date, formatDateFormat(format) + " HH:mm",
|
|
27
|
+
return date_and_time_1.default.format(date, formatDateFormat(format) + " HH:mm", { timeZone: "UTC" });
|
|
28
28
|
}
|
|
29
29
|
exports.formatDateTime = formatDateTime;
|
|
30
30
|
function formatDateFormat(format) {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
class ExpressRequestTracker {
|
|
4
|
+
activeRequests = 0;
|
|
5
|
+
track() {
|
|
6
|
+
return (_req, res, next) => {
|
|
7
|
+
this.activeRequests++;
|
|
8
|
+
res.on("finish", () => {
|
|
9
|
+
this.activeRequests--;
|
|
10
|
+
});
|
|
11
|
+
next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
nrOfActiveRequests() {
|
|
15
|
+
return this.activeRequests;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.default = ExpressRequestTracker;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const ExpressRequestTracker_1 = __importDefault(require("./ExpressRequestTracker"));
|
|
7
|
+
const node_events_1 = require("node:events");
|
|
8
|
+
// Create a minimal Response mock that can emit the 'finish' event
|
|
9
|
+
const createMockResponse = () => {
|
|
10
|
+
const emitter = new node_events_1.EventEmitter();
|
|
11
|
+
return Object.assign(emitter, {
|
|
12
|
+
emitFinish: () => emitter.emit("finish"),
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
describe("ExpressRequestTracker", () => {
|
|
16
|
+
test("increments active requests on middleware entry and calls next", () => {
|
|
17
|
+
const tracker = new ExpressRequestTracker_1.default();
|
|
18
|
+
const middleware = tracker.track();
|
|
19
|
+
const res = createMockResponse();
|
|
20
|
+
const next = jest.fn();
|
|
21
|
+
expect(tracker.nrOfActiveRequests()).toBe(0);
|
|
22
|
+
middleware({}, res, next);
|
|
23
|
+
expect(tracker.nrOfActiveRequests()).toBe(1);
|
|
24
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
25
|
+
// Finish should decrement
|
|
26
|
+
res.emitFinish();
|
|
27
|
+
expect(tracker.nrOfActiveRequests()).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
test("decrements active requests when response finishes", () => {
|
|
30
|
+
const tracker = new ExpressRequestTracker_1.default();
|
|
31
|
+
const middleware = tracker.track();
|
|
32
|
+
const res = createMockResponse();
|
|
33
|
+
middleware({}, res, (() => { }));
|
|
34
|
+
expect(tracker.nrOfActiveRequests()).toBe(1);
|
|
35
|
+
res.emitFinish();
|
|
36
|
+
expect(tracker.nrOfActiveRequests()).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
test("tracks multiple concurrent requests and decrements in order", () => {
|
|
39
|
+
const tracker = new ExpressRequestTracker_1.default();
|
|
40
|
+
const middleware = tracker.track();
|
|
41
|
+
const res1 = createMockResponse();
|
|
42
|
+
const res2 = createMockResponse();
|
|
43
|
+
middleware({}, res1, (() => { }));
|
|
44
|
+
middleware({}, res2, (() => { }));
|
|
45
|
+
expect(tracker.nrOfActiveRequests()).toBe(2);
|
|
46
|
+
// Finish the first request
|
|
47
|
+
res1.emitFinish();
|
|
48
|
+
expect(tracker.nrOfActiveRequests()).toBe(1);
|
|
49
|
+
// Finish the second request
|
|
50
|
+
res2.emitFinish();
|
|
51
|
+
expect(tracker.nrOfActiveRequests()).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import ExpressRequestTracker from "./ExpressRequestTracker";
|
|
2
|
+
type HealthServerOptions = {
|
|
3
|
+
port: number;
|
|
4
|
+
};
|
|
5
|
+
export default class HealthServer {
|
|
6
|
+
private readonly requestTracker;
|
|
7
|
+
private readonly options;
|
|
8
|
+
private readonly server;
|
|
9
|
+
private status;
|
|
10
|
+
private isReady;
|
|
11
|
+
private constructor();
|
|
12
|
+
static create(requestTracker: ExpressRequestTracker, options: HealthServerOptions): Promise<HealthServer>;
|
|
13
|
+
start(): Promise<void>;
|
|
14
|
+
stop(): Promise<void>;
|
|
15
|
+
setReady(value: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
7
|
+
const logger_1 = require("../logger");
|
|
8
|
+
class HealthServer {
|
|
9
|
+
requestTracker;
|
|
10
|
+
options;
|
|
11
|
+
server;
|
|
12
|
+
status = "stopped";
|
|
13
|
+
isReady = false;
|
|
14
|
+
constructor(requestTracker, options) {
|
|
15
|
+
this.requestTracker = requestTracker;
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.server = node_http_1.default.createServer((req, res) => {
|
|
18
|
+
if (req.url === "/live") {
|
|
19
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
20
|
+
return res.end(JSON.stringify({ status: "ok" }));
|
|
21
|
+
}
|
|
22
|
+
else if (req.url === "/ready") {
|
|
23
|
+
res.writeHead(this.isReady ? 200 : 503, { "Content-Type": "application/json" });
|
|
24
|
+
return res.end(JSON.stringify({
|
|
25
|
+
status: this.isReady ? "ok" : "not ready",
|
|
26
|
+
activeRequests: this.requestTracker.nrOfActiveRequests(),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
res.statusCode = 404;
|
|
31
|
+
return res.end();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
static async create(requestTracker, options) {
|
|
36
|
+
const hs = new HealthServer(requestTracker, options);
|
|
37
|
+
await hs.start();
|
|
38
|
+
return hs;
|
|
39
|
+
}
|
|
40
|
+
start() {
|
|
41
|
+
if (this.status !== "stopped") {
|
|
42
|
+
logger_1.logger.info("Cannot start Health server, due to invalid status", { status: this.status });
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
this.status = "starting";
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
this.server.listen(this.options.port, () => {
|
|
48
|
+
this.status = "started";
|
|
49
|
+
logger_1.logger.info("Started Health server");
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
stop() {
|
|
55
|
+
if (this.status !== "started") {
|
|
56
|
+
logger_1.logger.warn("Cannot stop Health server, due to invalid status", { status: this.status });
|
|
57
|
+
return Promise.reject(new Error("Health server not started"));
|
|
58
|
+
}
|
|
59
|
+
this.status = "stopping";
|
|
60
|
+
this.isReady = false;
|
|
61
|
+
return new Promise((resolve) => this.server.close(() => resolve()));
|
|
62
|
+
}
|
|
63
|
+
setReady(value) {
|
|
64
|
+
this.isReady = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.default = HealthServer;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const testUtils_1 = require("../testUtils");
|
|
7
|
+
(0, testUtils_1.silenceLogger)();
|
|
8
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
9
|
+
const node_events_1 = require("node:events");
|
|
10
|
+
const HealthServer_1 = __importDefault(require("./HealthServer"));
|
|
11
|
+
describe("HealthServer", () => {
|
|
12
|
+
const createFakeServer = () => {
|
|
13
|
+
const emitter = new node_events_1.EventEmitter();
|
|
14
|
+
const server = {
|
|
15
|
+
listen: jest.fn((...args) => {
|
|
16
|
+
const cb = args.find((a) => typeof a === "function");
|
|
17
|
+
if (cb)
|
|
18
|
+
cb();
|
|
19
|
+
return server;
|
|
20
|
+
}),
|
|
21
|
+
close: jest.fn((cb) => {
|
|
22
|
+
if (cb)
|
|
23
|
+
cb();
|
|
24
|
+
return server;
|
|
25
|
+
}),
|
|
26
|
+
on: jest.fn((event, listener) => {
|
|
27
|
+
emitter.on(event, listener);
|
|
28
|
+
return server;
|
|
29
|
+
}),
|
|
30
|
+
emit: emitter.emit.bind(emitter),
|
|
31
|
+
};
|
|
32
|
+
return server;
|
|
33
|
+
};
|
|
34
|
+
const createRes = () => {
|
|
35
|
+
const res = {
|
|
36
|
+
statusCode: 200,
|
|
37
|
+
headers: {},
|
|
38
|
+
writeHead: function (code, headers) {
|
|
39
|
+
this.statusCode = code;
|
|
40
|
+
this.headers = headers;
|
|
41
|
+
},
|
|
42
|
+
end: function (body) {
|
|
43
|
+
this.body = body;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
return res;
|
|
47
|
+
};
|
|
48
|
+
test("handles /live and /ready endpoints and start/stop lifecycle", async () => {
|
|
49
|
+
const fakeServer = createFakeServer();
|
|
50
|
+
// Capture handler passed to createServer
|
|
51
|
+
let capturedHandler;
|
|
52
|
+
const createServerSpy = jest
|
|
53
|
+
.spyOn(node_http_1.default, "createServer")
|
|
54
|
+
// @ts-expect-error simplify handler typing for test
|
|
55
|
+
.mockImplementation((handler) => {
|
|
56
|
+
capturedHandler = handler;
|
|
57
|
+
return fakeServer;
|
|
58
|
+
});
|
|
59
|
+
const requestTracker = { nrOfActiveRequests: jest.fn(() => 0) };
|
|
60
|
+
const hs = await HealthServer_1.default.create(requestTracker, { port: 0 });
|
|
61
|
+
expect(fakeServer.listen).toHaveBeenCalled();
|
|
62
|
+
// /live
|
|
63
|
+
const resLive = createRes();
|
|
64
|
+
capturedHandler({ url: "/live" }, resLive);
|
|
65
|
+
expect(resLive.statusCode).toBe(200);
|
|
66
|
+
expect(resLive.headers).toEqual({ "Content-Type": "application/json" });
|
|
67
|
+
expect(JSON.parse(resLive.body)).toEqual({ status: "ok" });
|
|
68
|
+
// /ready when not ready
|
|
69
|
+
const resReady1 = createRes();
|
|
70
|
+
capturedHandler({ url: "/ready" }, resReady1);
|
|
71
|
+
expect(resReady1.statusCode).toBe(503);
|
|
72
|
+
expect(JSON.parse(resReady1.body)).toEqual({
|
|
73
|
+
status: "not ready",
|
|
74
|
+
activeRequests: 0,
|
|
75
|
+
});
|
|
76
|
+
// Now mark ready and simulate active requests
|
|
77
|
+
requestTracker.nrOfActiveRequests.mockReturnValue(3);
|
|
78
|
+
hs.setReady(true);
|
|
79
|
+
const resReady2 = createRes();
|
|
80
|
+
capturedHandler({ url: "/ready" }, resReady2);
|
|
81
|
+
expect(resReady2.statusCode).toBe(200);
|
|
82
|
+
expect(JSON.parse(resReady2.body)).toEqual({
|
|
83
|
+
status: "ok",
|
|
84
|
+
activeRequests: 3,
|
|
85
|
+
});
|
|
86
|
+
// unknown route
|
|
87
|
+
const res404 = createRes();
|
|
88
|
+
capturedHandler({ url: "/unknown" }, res404);
|
|
89
|
+
expect(res404.statusCode).toBe(404);
|
|
90
|
+
await hs.stop();
|
|
91
|
+
expect(fakeServer.close).toHaveBeenCalled();
|
|
92
|
+
createServerSpy.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import ExpressRequestTracker from "./ExpressRequestTracker";
|
|
3
|
+
import HealthServer from "./HealthServer";
|
|
4
|
+
import { Server } from "node:http";
|
|
5
|
+
export default class TerminationHandler {
|
|
6
|
+
private readonly server;
|
|
7
|
+
private readonly hs;
|
|
8
|
+
private readonly requestTracker;
|
|
9
|
+
private readonly sockets;
|
|
10
|
+
constructor(server: Server, hs: HealthServer, requestTracker: ExpressRequestTracker);
|
|
11
|
+
shutdown(): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const logger_1 = require("../logger");
|
|
4
|
+
const node_timers_1 = require("node:timers");
|
|
5
|
+
const TERMINATION_SIGNALS = ["SIGTERM", "SIGHUP", "SIGINT"];
|
|
6
|
+
const SHUTDOWN_TIMEOUT = 30_000;
|
|
7
|
+
class TerminationHandler {
|
|
8
|
+
server;
|
|
9
|
+
hs;
|
|
10
|
+
requestTracker;
|
|
11
|
+
sockets = new Set();
|
|
12
|
+
constructor(server, hs, requestTracker) {
|
|
13
|
+
this.server = server;
|
|
14
|
+
this.hs = hs;
|
|
15
|
+
this.requestTracker = requestTracker;
|
|
16
|
+
for (const signal of TERMINATION_SIGNALS) {
|
|
17
|
+
process.on(signal, () => {
|
|
18
|
+
logger_1.logger.info("Received termination signal", { signal });
|
|
19
|
+
this.shutdown();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
server.on("connection", (socket) => {
|
|
23
|
+
this.sockets.add(socket);
|
|
24
|
+
socket.on("close", () => this.sockets.delete(socket));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
shutdown() {
|
|
28
|
+
this.hs.setReady(false);
|
|
29
|
+
logger_1.logger.info("Stopping server");
|
|
30
|
+
this.server.close();
|
|
31
|
+
const interval = (0, node_timers_1.setInterval)(() => {
|
|
32
|
+
const activeRequests = this.requestTracker.nrOfActiveRequests();
|
|
33
|
+
if (activeRequests === 0) {
|
|
34
|
+
(0, node_timers_1.clearInterval)(interval);
|
|
35
|
+
// Destroy any remaining sockets
|
|
36
|
+
this.sockets.forEach((socket) => socket.destroy());
|
|
37
|
+
this.hs.stop().finally(() => {
|
|
38
|
+
logger_1.logger.info("Server stopped");
|
|
39
|
+
process.exit(0);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
logger_1.logger.info("Waiting for requests to finish", { activeRequests });
|
|
44
|
+
}
|
|
45
|
+
}, 1_000);
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
logger_1.logger.info("Server forcefully stopped due to timeout");
|
|
48
|
+
this.sockets.forEach((socket) => socket.destroy());
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}, SHUTDOWN_TIMEOUT);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.default = TerminationHandler;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
const testUtils_1 = require("../testUtils");
|
|
30
|
+
(0, testUtils_1.silenceLogger)();
|
|
31
|
+
const node_events_1 = require("node:events");
|
|
32
|
+
// Mock node:timers so we can control callbacks (TerminationHandler imports from node:timers)
|
|
33
|
+
jest.mock("node:timers", () => ({
|
|
34
|
+
setInterval: jest.fn(),
|
|
35
|
+
clearInterval: jest.fn(),
|
|
36
|
+
setTimeout: jest.fn(),
|
|
37
|
+
}));
|
|
38
|
+
const timers = __importStar(require("node:timers"));
|
|
39
|
+
const TerminationHandler_1 = __importDefault(require("./TerminationHandler"));
|
|
40
|
+
describe("TerminationHandler", () => {
|
|
41
|
+
let intervalCb;
|
|
42
|
+
let timeoutCb;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
intervalCb = undefined;
|
|
45
|
+
timeoutCb = undefined;
|
|
46
|
+
timers.setInterval.mockImplementation((cb) => {
|
|
47
|
+
intervalCb = cb;
|
|
48
|
+
return 1;
|
|
49
|
+
});
|
|
50
|
+
timers.clearInterval.mockImplementation(() => { });
|
|
51
|
+
jest
|
|
52
|
+
.spyOn(globalThis, "setTimeout")
|
|
53
|
+
// @ts-ignore simplify types for test
|
|
54
|
+
.mockImplementation((cb) => {
|
|
55
|
+
timeoutCb = cb;
|
|
56
|
+
return 2;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
jest.resetAllMocks();
|
|
61
|
+
});
|
|
62
|
+
const createFakeServer = () => {
|
|
63
|
+
const emitter = new node_events_1.EventEmitter();
|
|
64
|
+
const server = {
|
|
65
|
+
close: jest.fn((cb) => {
|
|
66
|
+
if (cb)
|
|
67
|
+
cb();
|
|
68
|
+
return server;
|
|
69
|
+
}),
|
|
70
|
+
on: jest.fn((event, listener) => {
|
|
71
|
+
emitter.on(event, listener);
|
|
72
|
+
return server;
|
|
73
|
+
}),
|
|
74
|
+
emit: emitter.emit.bind(emitter),
|
|
75
|
+
};
|
|
76
|
+
return server;
|
|
77
|
+
};
|
|
78
|
+
const createSocket = () => {
|
|
79
|
+
const emitter = new node_events_1.EventEmitter();
|
|
80
|
+
const socket = emitter;
|
|
81
|
+
socket.destroy = jest.fn();
|
|
82
|
+
return socket;
|
|
83
|
+
};
|
|
84
|
+
test("shutdown waits for active requests to reach 0 then stops and exits 0", async () => {
|
|
85
|
+
const server = createFakeServer();
|
|
86
|
+
const hs = {
|
|
87
|
+
setReady: jest.fn(),
|
|
88
|
+
stop: jest.fn(() => ({
|
|
89
|
+
finally: (cb) => {
|
|
90
|
+
cb();
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
},
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
const requestTracker = { nrOfActiveRequests: jest.fn(() => 0) };
|
|
96
|
+
const exitSpy = jest
|
|
97
|
+
.spyOn(process, "exit")
|
|
98
|
+
// never return to process
|
|
99
|
+
.mockImplementation(((..._args) => undefined));
|
|
100
|
+
const th = new TerminationHandler_1.default(server, hs, requestTracker);
|
|
101
|
+
th.shutdown();
|
|
102
|
+
expect(hs.setReady).toHaveBeenCalledWith(false);
|
|
103
|
+
expect(server.close).toHaveBeenCalled();
|
|
104
|
+
// First interval tick should see 0 active and exit
|
|
105
|
+
intervalCb?.();
|
|
106
|
+
// ensure hs.stop called and exit called with 0
|
|
107
|
+
expect(hs.stop).toHaveBeenCalled();
|
|
108
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
109
|
+
});
|
|
110
|
+
test("shutdown polls until requests finish then exits 0", async () => {
|
|
111
|
+
const server = createFakeServer();
|
|
112
|
+
const hs = {
|
|
113
|
+
setReady: jest.fn(),
|
|
114
|
+
stop: jest.fn(() => ({
|
|
115
|
+
finally: (cb) => {
|
|
116
|
+
cb();
|
|
117
|
+
return Promise.resolve();
|
|
118
|
+
},
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
const requestTracker = { nrOfActiveRequests: jest.fn() };
|
|
122
|
+
// Return >0 first, then 0
|
|
123
|
+
requestTracker.nrOfActiveRequests.mockReturnValueOnce(2).mockReturnValueOnce(0);
|
|
124
|
+
const exitSpy = jest
|
|
125
|
+
.spyOn(process, "exit")
|
|
126
|
+
.mockImplementation(((..._args) => undefined));
|
|
127
|
+
const th = new TerminationHandler_1.default(server, hs, requestTracker);
|
|
128
|
+
th.shutdown();
|
|
129
|
+
// First tick: still waiting
|
|
130
|
+
intervalCb?.();
|
|
131
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
132
|
+
// Second tick: now 0
|
|
133
|
+
intervalCb?.();
|
|
134
|
+
expect(hs.stop).toHaveBeenCalled();
|
|
135
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
136
|
+
});
|
|
137
|
+
test("forceful stop after 30s destroys sockets and exits 1", () => {
|
|
138
|
+
const server = createFakeServer();
|
|
139
|
+
const hs = { setReady: jest.fn(), stop: jest.fn(() => Promise.resolve()) };
|
|
140
|
+
const requestTracker = { nrOfActiveRequests: jest.fn(() => 5) }; // never reaches 0
|
|
141
|
+
const exitSpy = jest
|
|
142
|
+
.spyOn(process, "exit")
|
|
143
|
+
.mockImplementation(((..._args) => undefined));
|
|
144
|
+
const th = new TerminationHandler_1.default(server, hs, requestTracker);
|
|
145
|
+
// Register a socket via the server's connection event
|
|
146
|
+
const socket = createSocket();
|
|
147
|
+
server.emit("connection", socket);
|
|
148
|
+
th.shutdown();
|
|
149
|
+
// Before timeout callback, no exit
|
|
150
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
151
|
+
expect(socket.destroy).not.toHaveBeenCalled();
|
|
152
|
+
// Trigger timeout
|
|
153
|
+
timeoutCb?.();
|
|
154
|
+
expect(socket.destroy).toHaveBeenCalled();
|
|
155
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TerminationHandler = exports.HealthServer = exports.ExpressRequestTracker = void 0;
|
|
7
|
+
const ExpressRequestTracker_1 = __importDefault(require("./ExpressRequestTracker"));
|
|
8
|
+
exports.ExpressRequestTracker = ExpressRequestTracker_1.default;
|
|
9
|
+
const HealthServer_1 = __importDefault(require("./HealthServer"));
|
|
10
|
+
exports.HealthServer = HealthServer_1.default;
|
|
11
|
+
const TerminationHandler_1 = __importDefault(require("./TerminationHandler"));
|
|
12
|
+
exports.TerminationHandler = TerminationHandler_1.default;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.silenceLogger = void 0;
|
|
4
|
+
// Utility to silence logger output in tests, must be at the top of the test file
|
|
5
|
+
const silenceLogger = () => jest.mock("./logger", () => ({
|
|
6
|
+
logger: {
|
|
7
|
+
info: jest.fn(),
|
|
8
|
+
warn: jest.fn(),
|
|
9
|
+
error: jest.fn(),
|
|
10
|
+
debug: jest.fn(),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
exports.silenceLogger = silenceLogger;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moreapp/common-nodejs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"format:fix": "yarn prettier --write",
|
|
14
14
|
"lint:check": "eslint . --ext ts",
|
|
15
15
|
"lint:fix": "yarn lint:check --fix",
|
|
16
|
-
"prepare": "husky
|
|
16
|
+
"prepare": "husky"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist/**/*"
|
|
@@ -35,27 +35,29 @@
|
|
|
35
35
|
"@opentelemetry/semantic-conventions": "1.22.0",
|
|
36
36
|
"axios": "1.13.2",
|
|
37
37
|
"content-disposition-parser": "1.0.2",
|
|
38
|
-
"date-and-time": "
|
|
38
|
+
"date-and-time": "4.1.0",
|
|
39
|
+
"express": "5.2.1",
|
|
39
40
|
"lodash": "4.17.21",
|
|
40
41
|
"winston": "3.13.0",
|
|
41
42
|
"zod": "3.25.76"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
45
|
+
"@types/express": "5.0.6",
|
|
44
46
|
"@types/jest": "29.5.11",
|
|
45
47
|
"@types/lodash": "4.17.0",
|
|
46
|
-
"@types/node": "
|
|
48
|
+
"@types/node": "24.10.1",
|
|
47
49
|
"@typescript-eslint/eslint-plugin": "6.21.0",
|
|
48
50
|
"@typescript-eslint/parser": "6.21.0",
|
|
49
51
|
"eslint": "8.57.0",
|
|
50
|
-
"eslint-config-airbnb-typescript": "
|
|
52
|
+
"eslint-config-airbnb-typescript": "18.0.0",
|
|
51
53
|
"eslint-config-prettier": "9.1.0",
|
|
52
54
|
"eslint-plugin-import": "2.29.1",
|
|
53
55
|
"eslint-plugin-prettier": "5.1.3",
|
|
54
56
|
"husky": "9.0.7",
|
|
55
57
|
"jest": "29.7.0",
|
|
56
|
-
"jest-mock-extended": "
|
|
58
|
+
"jest-mock-extended": "4.0.0",
|
|
57
59
|
"lint-staged": "16.2.6",
|
|
58
|
-
"nock": "
|
|
60
|
+
"nock": "14.0.10",
|
|
59
61
|
"prettier": "3.2.4",
|
|
60
62
|
"ts-jest": "29.1.2",
|
|
61
63
|
"ts-node": "10.9.2",
|