@darco2903/pulse 0.0.0-stage → 1.0.0-beta.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 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
- # Temporary Holding Version
1
+ # @darco2903/pulse
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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
+ }
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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
+ });
@@ -0,0 +1,3 @@
1
+ export { health, createHealth } from "./health.js";
2
+ export { binary } from "./checks.js";
3
+ export type { Check, HealthOptions, HealthReport, HealthStatus, Logger } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { health, createHealth } from "./health.js";
2
+ export { binary } from "./checks.js";
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-beta.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
  }