@darco2903/pulse 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/dist/checks.d.ts +7 -0
- package/dist/checks.js +22 -0
- package/dist/health.d.ts +11 -0
- package/dist/health.js +110 -0
- package/dist/healthcheck.d.ts +2 -0
- package/dist/healthcheck.js +27 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +1 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Darco2903
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @darco2903/pulse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`/health` endpoint and Docker healthcheck for Node.js web servers. No dependencies, works with Express and `node:http`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @darco2903/pulse
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { health, binary } from "@darco2903/pulse";
|
|
15
|
+
|
|
16
|
+
// Before the request logger and the catch-all routes
|
|
17
|
+
app.use(
|
|
18
|
+
health({
|
|
19
|
+
version: pkg.version,
|
|
20
|
+
logger, // pino, console...
|
|
21
|
+
checks: {
|
|
22
|
+
db: { check: () => db.query("SELECT 1"), critical: true },
|
|
23
|
+
ffmpeg: binary("ffmpeg", ["-version"]),
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`GET /health`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"status": "ok",
|
|
34
|
+
"version": "2.3.0",
|
|
35
|
+
"commit": "05cb060",
|
|
36
|
+
"buildTime": "2026-11-05T10:00:00Z",
|
|
37
|
+
"uptime": 3600,
|
|
38
|
+
"checks": { "db": "ok", "ffmpeg": "ok" }
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Status | When | HTTP |
|
|
43
|
+
| ---------- | -------------------------- | ---- |
|
|
44
|
+
| `ok` | every check passes | 200 |
|
|
45
|
+
| `degraded` | a non-critical check fails | 200 |
|
|
46
|
+
| `down` | a critical check fails | 503 |
|
|
47
|
+
|
|
48
|
+
The endpoint is public, so it only says whether each check passed. Why a check failed, and what a check returned (like the version of a binary), is logged instead, and only when a check changes state, so a failing check doesn't flood the logs.
|
|
49
|
+
|
|
50
|
+
### Options
|
|
51
|
+
|
|
52
|
+
| Option | Default |
|
|
53
|
+
| ----------- | ------------------------------------------------ |
|
|
54
|
+
| `version` | required |
|
|
55
|
+
| `commit` | `$GIT_COMMIT`, then `git rev-parse --short HEAD` |
|
|
56
|
+
| `buildTime` | `$BUILD_TIME` |
|
|
57
|
+
| `path` | `/health` |
|
|
58
|
+
| `checks` | none |
|
|
59
|
+
| `timeout` | `2000` ms per check |
|
|
60
|
+
| `logger` | `console` |
|
|
61
|
+
|
|
62
|
+
### Checks
|
|
63
|
+
|
|
64
|
+
A check fails when it throws, rejects or times out.
|
|
65
|
+
|
|
66
|
+
| Field | Description |
|
|
67
|
+
| ---------- | ---------------------------------------------------- |
|
|
68
|
+
| `check` | the function to run |
|
|
69
|
+
| `critical` | if it fails, the app is `down` instead of `degraded` |
|
|
70
|
+
| `once` | run once at startup and keep the result |
|
|
71
|
+
| `timeout` | overrides the `timeout` option |
|
|
72
|
+
|
|
73
|
+
`binary(command, args = ["--version"])` is a `once` check that runs a binary and logs the first line of its output.
|
|
74
|
+
|
|
75
|
+
Concurrent requests share one run of the checks. For a server that isn't Express or `node:http`, `createHealth(options).getHealth()` gives the report without the HTTP part.
|
|
76
|
+
|
|
77
|
+
## Docker
|
|
78
|
+
|
|
79
|
+
```dockerfile
|
|
80
|
+
ARG GIT_COMMIT
|
|
81
|
+
ARG BUILD_TIME
|
|
82
|
+
ENV GIT_COMMIT=$GIT_COMMIT BUILD_TIME=$BUILD_TIME
|
|
83
|
+
|
|
84
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
|
85
|
+
CMD ["node", "node_modules/@darco2903/pulse/dist/healthcheck.js"]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
With the build args passed in `compose.yml`:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
build:
|
|
92
|
+
context: .
|
|
93
|
+
args:
|
|
94
|
+
GIT_COMMIT: ${GIT_COMMIT:-}
|
|
95
|
+
BUILD_TIME: ${BUILD_TIME:-}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) docker compose up -d --build
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`healthcheck.js` exits 0 when the endpoint answers 200 (`ok` or `degraded`). It calls the URL given as first argument, then `$HEALTHCHECK_URL`, then `https://127.0.0.1:$SERVER_PORT/health`. The certificate isn't verified on loopback, since it is for the public domain.
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Check } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Checks that a binary (ffmpeg, yt-dlp...) runs, once at startup. Its version (first line of
|
|
4
|
+
* the output) is logged.
|
|
5
|
+
* @param args ffmpeg needs ["-version"]
|
|
6
|
+
*/
|
|
7
|
+
export declare function binary(command: string, args?: string[], options?: Omit<Check, "check" | "once">): Check;
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Checks that a binary (ffmpeg, yt-dlp...) runs, once at startup. Its version (first line of
|
|
4
|
+
* the output) is logged.
|
|
5
|
+
* @param args ffmpeg needs ["-version"]
|
|
6
|
+
*/
|
|
7
|
+
export function binary(command, args = ["--version"], options = {}) {
|
|
8
|
+
return {
|
|
9
|
+
...options,
|
|
10
|
+
once: true,
|
|
11
|
+
check: () => new Promise((resolve, reject) => {
|
|
12
|
+
execFile(command, args, (err, stdout) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
reject(err);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
resolve(stdout.split("\n")[0].trim());
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/health.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
+
import type { HealthOptions, HealthReport } from "./types.js";
|
|
3
|
+
/** The health report without the HTTP part, for servers that aren't Express or node:http */
|
|
4
|
+
export declare function createHealth(options: HealthOptions): {
|
|
5
|
+
getHealth: () => Promise<HealthReport>;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Middleware answering GET and HEAD on /health, works with Express and node:http. Register it
|
|
9
|
+
* before the request logger and the catch-all routes.
|
|
10
|
+
*/
|
|
11
|
+
export declare function health(options: HealthOptions): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
|
package/dist/health.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
const DEFAULT_TIMEOUT = 2000;
|
|
3
|
+
async function withTimeout(promise, ms) {
|
|
4
|
+
let timer;
|
|
5
|
+
const timeout = new Promise((_, reject) => {
|
|
6
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
7
|
+
});
|
|
8
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
9
|
+
}
|
|
10
|
+
async function runCheck(check, timeout) {
|
|
11
|
+
try {
|
|
12
|
+
// Through then() so a check that throws synchronously is a failure too
|
|
13
|
+
const value = await withTimeout(Promise.resolve().then(() => check.check()), check.timeout ?? timeout);
|
|
14
|
+
return { ok: true, detail: typeof value === "string" && value ? value : undefined };
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** For dev, where the build args aren't set */
|
|
21
|
+
function gitCommit() {
|
|
22
|
+
try {
|
|
23
|
+
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
}).trim();
|
|
27
|
+
return commit || null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The health report without the HTTP part, for servers that aren't Express or node:http */
|
|
34
|
+
export function createHealth(options) {
|
|
35
|
+
const logger = options.logger ?? console;
|
|
36
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
37
|
+
const checks = Object.entries(options.checks ?? {});
|
|
38
|
+
// || and not ??: an unset Docker build arg gives an empty variable
|
|
39
|
+
const commit = options.commit ?? (process.env.GIT_COMMIT || gitCommit());
|
|
40
|
+
const buildTime = options.buildTime ?? (process.env.BUILD_TIME || null);
|
|
41
|
+
// Only changes are logged, so a check failing on every healthcheck doesn't flood the logs
|
|
42
|
+
const lastOk = new Map();
|
|
43
|
+
const run = async (name, check) => {
|
|
44
|
+
return runCheck(check, timeout).then((result) => {
|
|
45
|
+
if (lastOk.get(name) !== result.ok) {
|
|
46
|
+
lastOk.set(name, result.ok);
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
logger.info(`Health check ${name} OK${result.detail ? `: ${result.detail}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
logger.warn(`Health check ${name} failed: ${result.error}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const onceResults = new Map(checks.filter(([, check]) => check.once).map(([name, check]) => [name, run(name, check)]));
|
|
58
|
+
const evaluate = async () => {
|
|
59
|
+
const results = await Promise.all(checks.map(([name, check]) => onceResults.get(name) ?? run(name, check)));
|
|
60
|
+
const failed = checks.filter((_, i) => !results[i].ok);
|
|
61
|
+
const status = failed.some(([, check]) => check.critical)
|
|
62
|
+
? "down"
|
|
63
|
+
: failed.length > 0
|
|
64
|
+
? "degraded"
|
|
65
|
+
: "ok";
|
|
66
|
+
return {
|
|
67
|
+
status,
|
|
68
|
+
version: options.version,
|
|
69
|
+
commit,
|
|
70
|
+
buildTime,
|
|
71
|
+
uptime: Math.floor(process.uptime()),
|
|
72
|
+
...(checks.length > 0 && {
|
|
73
|
+
checks: Object.fromEntries(checks.map(([name], i) => [name, results[i].ok ? "ok" : "fail"])),
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
let pending = null;
|
|
78
|
+
/** Requests that come in while the checks run share their result, a burst doesn't multiply them */
|
|
79
|
+
const getHealth = () => {
|
|
80
|
+
pending ??= evaluate().finally(() => {
|
|
81
|
+
pending = null;
|
|
82
|
+
});
|
|
83
|
+
return pending;
|
|
84
|
+
};
|
|
85
|
+
return { getHealth };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Middleware answering GET and HEAD on /health, works with Express and node:http. Register it
|
|
89
|
+
* before the request logger and the catch-all routes.
|
|
90
|
+
*/
|
|
91
|
+
export function health(options) {
|
|
92
|
+
const path = options.path ?? "/health";
|
|
93
|
+
const { getHealth } = createHealth(options);
|
|
94
|
+
return (req, res, next) => {
|
|
95
|
+
const pathname = req.url?.split("?")[0];
|
|
96
|
+
if (pathname !== path || (req.method !== "GET" && req.method !== "HEAD")) {
|
|
97
|
+
next();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
getHealth().then((report) => {
|
|
101
|
+
const body = JSON.stringify(report);
|
|
102
|
+
res.writeHead(report.status === "down" ? 503 : 200, {
|
|
103
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
104
|
+
"Content-Length": Buffer.byteLength(body),
|
|
105
|
+
"Cache-Control": "no-store",
|
|
106
|
+
});
|
|
107
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
108
|
+
}, next);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* For Docker HEALTHCHECK: exits 0 when /health answers 200 (ok or degraded), 1 otherwise.
|
|
4
|
+
* The URL is the first argument, then $HEALTHCHECK_URL, then https://127.0.0.1:$SERVER_PORT/health
|
|
5
|
+
*/
|
|
6
|
+
import http from "http";
|
|
7
|
+
import https from "https";
|
|
8
|
+
const url = new URL(process.argv[2] ?? process.env.HEALTHCHECK_URL ?? `https://127.0.0.1:${process.env.SERVER_PORT ?? 8080}/health`);
|
|
9
|
+
const LOOPBACK = ["127.0.0.1", "localhost", "[::1]"];
|
|
10
|
+
const options = {
|
|
11
|
+
// The certificate is for the public domain, not for 127.0.0.1: there is nothing to verify on loopback
|
|
12
|
+
rejectUnauthorized: !LOOPBACK.includes(url.hostname),
|
|
13
|
+
timeout: 4000,
|
|
14
|
+
};
|
|
15
|
+
function onResponse(res) {
|
|
16
|
+
res.resume();
|
|
17
|
+
if (res.statusCode !== 200) {
|
|
18
|
+
console.error(`${url} answered ${res.statusCode}`);
|
|
19
|
+
}
|
|
20
|
+
process.exit(res.statusCode === 200 ? 0 : 1);
|
|
21
|
+
}
|
|
22
|
+
const req = url.protocol === "http:" ? http.get(url, options, onResponse) : https.get(url, options, onResponse);
|
|
23
|
+
req.on("timeout", () => req.destroy(new Error(`${url} timed out`)));
|
|
24
|
+
req.on("error", (err) => {
|
|
25
|
+
console.error(err.message);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface Check {
|
|
2
|
+
/** Throws or rejects when the check fails. A returned string (a version...) is logged, never sent */
|
|
3
|
+
check: () => unknown;
|
|
4
|
+
/** A failing critical check makes the app "down" (503), any other one only "degraded" (200) */
|
|
5
|
+
critical?: boolean;
|
|
6
|
+
/** Run once at startup and keep the result, for things that don't change while the app runs */
|
|
7
|
+
once?: boolean;
|
|
8
|
+
/** In milliseconds, defaults to the timeout of the options */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
}
|
|
11
|
+
/** pino, console... */
|
|
12
|
+
export interface Logger {
|
|
13
|
+
info(message: string): void;
|
|
14
|
+
warn(message: string): void;
|
|
15
|
+
}
|
|
16
|
+
export interface HealthOptions {
|
|
17
|
+
version: string;
|
|
18
|
+
/** Defaults to $GIT_COMMIT, then to `git rev-parse --short HEAD` */
|
|
19
|
+
commit?: string;
|
|
20
|
+
/** Defaults to $BUILD_TIME */
|
|
21
|
+
buildTime?: string;
|
|
22
|
+
/** Defaults to /health */
|
|
23
|
+
path?: string;
|
|
24
|
+
checks?: Record<string, Check>;
|
|
25
|
+
/** Timeout of each check in milliseconds, defaults to 2000 */
|
|
26
|
+
timeout?: number;
|
|
27
|
+
/** Defaults to console */
|
|
28
|
+
logger?: Logger;
|
|
29
|
+
}
|
|
30
|
+
export type HealthStatus = "ok" | "degraded" | "down";
|
|
31
|
+
/** Public: check results are only ok/fail, the reason of a failure is logged */
|
|
32
|
+
export interface HealthReport {
|
|
33
|
+
status: HealthStatus;
|
|
34
|
+
version: string;
|
|
35
|
+
commit: string | null;
|
|
36
|
+
buildTime: string | null;
|
|
37
|
+
/** In seconds */
|
|
38
|
+
uptime: number;
|
|
39
|
+
checks?: Record<string, "ok" | "fail">;
|
|
40
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@darco2903/pulse",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"author": "Darco2903",
|
|
5
|
+
"description": "/health endpoint and Docker healthcheck for Node.js web servers",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"health",
|
|
8
|
+
"healthcheck",
|
|
9
|
+
"express",
|
|
10
|
+
"docker"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"pulse-check": "dist/healthcheck.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/Darco2903/pulse#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/Darco2903/pulse/issues"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/Darco2903/pulse.git"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^24.19.2",
|
|
44
|
+
"rimraf": "^6.1.3",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"vitest": "^5.0.5"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"check": "tsc --noEmit && tsc -p tests",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"clean": "rimraf -g dist darco2903-pulse-*.tgz"
|
|
53
|
+
}
|
|
6
54
|
}
|