@sigil-dev/plugin-health 0.9.1 → 0.9.3

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.
Files changed (3) hide show
  1. package/README.md +131 -131
  2. package/index.ts +150 -148
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,132 +1,132 @@
1
- # @sigil-dev/plugin-health
2
-
3
- Adds a `/health` endpoint to your Grimoire application.
4
- In coordinator mode it fans out to all workers and aggregates their status, respecting your names defined in the namepool plugin
5
- In worker mode it reports local uptime, version, and any custom fields you provide.
6
-
7
- ## Installation
8
-
9
- ```sh
10
- bun add @sigil-dev/plugin-health
11
- ```
12
- ## Why?
13
- Unified health endpoint format allows for easier library development, for example a health -> Discord webhook or a
14
- headless status page
15
-
16
- ## Usage
17
-
18
- ```ts
19
- import { defineConfig } from "@sigil-dev/grimoire";
20
- import { healthPlugin } from "@sigil-dev/plugin-health";
21
-
22
- export default defineConfig({
23
- plugins: [
24
- healthPlugin(),
25
- ],
26
- });
27
- ```
28
-
29
- Hit `/health` to get a full cluster report:
30
-
31
- ```json
32
- {
33
- "ok": true,
34
- "version": "1.0.0",
35
- "uptime": 3600,
36
- "cluster": [
37
- { "name": "api-0", "status": "up", "pid": 1234, "version": "1.0.0", "lastSeen": 1718000000000 },
38
- { "name": "ws-0", "status": "up", "pid": 1235, "version": "1.0.0", "lastSeen": 1718000000000 }
39
- ]
40
- }
41
- ```
42
-
43
- Returns `200` when all workers are up or reserved, `503` when any worker is down.
44
-
45
- ## Options
46
-
47
- ```ts
48
- interface HealthPluginOptions {
49
- /** Route to serve health at. Default: "/health" */
50
- path?: string;
51
- /** Extra fields to include in the response. */
52
- extend?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
53
- /** Worker names to track. Auto-detected from plugin-namepool if present. */
54
- names?: string[];
55
- }
56
- ```
57
-
58
- ### Custom fields
59
-
60
- Use `extend` to add dependency health checks or any other metadata:
61
-
62
- ```ts
63
- healthPlugin({
64
- extend: async () => ({
65
- db: await checkPostgres(),
66
- redis: await checkRedis(),
67
- version_sha: process.env.GIT_SHA,
68
- }),
69
- })
70
- ```
71
-
72
- ## Worker status values
73
-
74
- | Status | Meaning |
75
- |---|---|
76
- | `"up"` | Worker is running and responding. |
77
- | `"down"` | Worker exists in coordinator but did not respond within 2 seconds. |
78
- | `"reserved"` | Name is in the pool but no worker slot is configured for it. |
79
-
80
- `"reserved"` workers do not fail the health check — they indicate a name is allocated but not yet deployed.
81
-
82
- ## Response shape
83
-
84
- ### Coordinator (`/health`)
85
-
86
- ```ts
87
- interface HealthResponse {
88
- ok: boolean;
89
- version: string;
90
- uptime: number; // seconds since coordinator start
91
- cluster: WorkerHealth[];
92
- [key: string]: unknown; // any fields added via extend()
93
- }
94
-
95
- interface WorkerHealth {
96
- name: string;
97
- status: "up" | "down" | "reserved";
98
- pid?: number;
99
- version: string;
100
- lastSeen: number | null; // null if the worker never started
101
- }
102
- ```
103
-
104
- ### Worker (`/health`)
105
-
106
- ```ts
107
- {
108
- ok: true,
109
- version: "1.0.0",
110
- uptime: 120,
111
- instanceId: "alpha",
112
- pid: 1234,
113
- // ...any fields from extend()
114
- }
115
- ```
116
-
117
- ## Integration with plugin-namepool
118
-
119
- When `@sigil-dev/plugin-namepool` is also registered, `plugin-health` automatically detects it via the plugin registry and uses the name pool to report `reserved` workers. No configuration needed.
120
-
121
- ```ts
122
- import { namepool } from "@sigil-dev/plugin-namepool";
123
- import { healthPlugin } from "@sigil-dev/plugin-health";
124
-
125
- export default defineConfig({
126
- plugins: [
127
- namepool({ names: ["alpha", "beta", "gamma"] }),
128
- healthPlugin(),
129
- ],
130
- scale: { api: 2 }, // only 2 workers — "gamma" shows as reserved
131
- });
1
+ # @sigil-dev/plugin-health
2
+
3
+ Adds a `/health` endpoint to your Grimoire application.
4
+ In coordinator mode it fans out to all workers and aggregates their status, respecting your names defined in the namepool plugin
5
+ In worker mode it reports local uptime, version, and any custom fields you provide.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ bun add @sigil-dev/plugin-health
11
+ ```
12
+ ## Why?
13
+ Unified health endpoint format allows for easier library development, for example a health -> Discord webhook or a
14
+ headless status page
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { defineConfig } from "@sigil-dev/grimoire";
20
+ import { healthPlugin } from "@sigil-dev/plugin-health";
21
+
22
+ export default defineConfig({
23
+ plugins: [
24
+ healthPlugin(),
25
+ ],
26
+ });
27
+ ```
28
+
29
+ Hit `/health` to get a full cluster report:
30
+
31
+ ```json
32
+ {
33
+ "ok": true,
34
+ "version": "1.0.0",
35
+ "uptime": 3600,
36
+ "cluster": [
37
+ { "name": "api-0", "status": "up", "pid": 1234, "version": "1.0.0", "lastSeen": 1718000000000 },
38
+ { "name": "ws-0", "status": "up", "pid": 1235, "version": "1.0.0", "lastSeen": 1718000000000 }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ Returns `200` when all workers are up or reserved, `503` when any worker is down.
44
+
45
+ ## Options
46
+
47
+ ```ts
48
+ interface HealthPluginOptions {
49
+ /** Route to serve health at. Default: "/health" */
50
+ path?: string;
51
+ /** Extra fields to include in the response. */
52
+ extend?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
53
+ /** Worker names to track. Auto-detected from plugin-namepool if present. */
54
+ names?: string[];
55
+ }
56
+ ```
57
+
58
+ ### Custom fields
59
+
60
+ Use `extend` to add dependency health checks or any other metadata:
61
+
62
+ ```ts
63
+ healthPlugin({
64
+ extend: async () => ({
65
+ db: await checkPostgres(),
66
+ redis: await checkRedis(),
67
+ version_sha: process.env.GIT_SHA,
68
+ }),
69
+ })
70
+ ```
71
+
72
+ ## Worker status values
73
+
74
+ | Status | Meaning |
75
+ |---|---|
76
+ | `"up"` | Worker is running and responding. |
77
+ | `"down"` | Worker exists in coordinator but did not respond within 2 seconds. |
78
+ | `"reserved"` | Name is in the pool but no worker slot is configured for it. |
79
+
80
+ `"reserved"` workers do not fail the health check — they indicate a name is allocated but not yet deployed.
81
+
82
+ ## Response shape
83
+
84
+ ### Coordinator (`/health`)
85
+
86
+ ```ts
87
+ interface HealthResponse {
88
+ ok: boolean;
89
+ version: string;
90
+ uptime: number; // seconds since coordinator start
91
+ cluster: WorkerHealth[];
92
+ [key: string]: unknown; // any fields added via extend()
93
+ }
94
+
95
+ interface WorkerHealth {
96
+ name: string;
97
+ status: "up" | "down" | "reserved";
98
+ pid?: number;
99
+ version: string;
100
+ lastSeen: number | null; // null if the worker never started
101
+ }
102
+ ```
103
+
104
+ ### Worker (`/health`)
105
+
106
+ ```ts
107
+ {
108
+ ok: true,
109
+ version: "1.0.0",
110
+ uptime: 120,
111
+ instanceId: "alpha",
112
+ pid: 1234,
113
+ // ...any fields from extend()
114
+ }
115
+ ```
116
+
117
+ ## Integration with plugin-namepool
118
+
119
+ When `@sigil-dev/plugin-namepool` is also registered, `plugin-health` automatically detects it via the plugin registry and uses the name pool to report `reserved` workers. No configuration needed.
120
+
121
+ ```ts
122
+ import { namepool } from "@sigil-dev/plugin-namepool";
123
+ import { healthPlugin } from "@sigil-dev/plugin-health";
124
+
125
+ export default defineConfig({
126
+ plugins: [
127
+ namepool({ names: ["alpha", "beta", "gamma"] }),
128
+ healthPlugin(),
129
+ ],
130
+ scale: { api: 2 }, // only 2 workers — "gamma" shows as reserved
131
+ });
132
132
  ```
package/index.ts CHANGED
@@ -1,148 +1,150 @@
1
- import type { CoordinatorContext, GrimoirePlugin } from "@sigil-dev/grimoire";
2
-
3
- export interface HealthPluginOptions {
4
- /** Route to serve health at. Default: /health */
5
- path?: string;
6
- /** Extra fields to include in the response */
7
- extend?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
8
- /** By default the plugin pulls names from @sigil-dev/plugin-namepool, but you can specify overrides.*/
9
- names?: string[];
10
- }
11
-
12
- export type WorkerHealthStatus = "up" | "down" | "reserved";
13
-
14
- export interface WorkerHealth {
15
- name: string;
16
- status: WorkerHealthStatus;
17
- pid?: number;
18
- version: string;
19
- lastSeen: number | null; // null = never existed
20
- }
21
- export interface HealthResponse {
22
- ok: boolean;
23
- version: string;
24
- uptime: number;
25
- instanceId?: string;
26
- cluster?: WorkerHealth[];
27
- [key: string]: unknown;
28
- }
29
-
30
- export function healthPlugin(
31
- options: HealthPluginOptions = {},
32
- ): GrimoirePlugin {
33
- const healthPath = options.path ?? "/health";
34
- const startedAt = Date.now();
35
- let names: string[] | undefined = options.names;
36
-
37
- // set by onCoordinatorStart — stays null in worker context
38
- let coordinatorCtx: CoordinatorContext | null = null;
39
-
40
- // last successful contact per worker — lets a status page show
41
- // "up but hasn't heartbeated recently" vs a fresh respawn
42
- const lastSeen = new Map<string, number>();
43
-
44
- return {
45
- name: "@sigil-dev/plugin-health",
46
-
47
- onPluginsResolved(ctx) {
48
- const pool = ctx.plugins.get("namepool") as any;
49
- names ??= pool?.names;
50
- },
51
-
52
- onCoordinatorStart(ctx) {
53
- coordinatorCtx = ctx;
54
- for (const w of ctx.workers) {
55
- lastSeen.set(w.name ?? `${w.mode}-${w.index}`, Date.now());
56
- }
57
- },
58
-
59
- onWorkerReady(worker) {
60
- lastSeen.set(worker.name ?? `${worker.mode}-${worker.index}`, Date.now());
61
- },
62
-
63
- async onRequest(req, next) {
64
- const url = new URL(req.url);
65
- if (url.pathname !== healthPath) return next();
66
-
67
- // ── coordinator context: fan out to all workers ──
68
- if (coordinatorCtx) {
69
- const cluster: WorkerHealth[] = await Promise.all(
70
- coordinatorCtx.workers.map(async (w): Promise<WorkerHealth> => {
71
- const key = w.name ?? `${w.mode}-${w.index}`;
72
- try {
73
- const res = await fetch(`${w.internalUrl}${healthPath}`, {
74
- signal: AbortSignal.timeout(2_000),
75
- headers: { "X-Grimoire-Internal": coordinatorCtx!.secret },
76
- });
77
- if (res.ok) {
78
- lastSeen.set(key, Date.now());
79
- const data = (await res.json()) as HealthResponse;
80
- return {
81
- name: key,
82
- status: "up",
83
- pid: w.pid,
84
- version: data.version ?? "",
85
- lastSeen: lastSeen.get(key)!,
86
- };
87
- }
88
- } catch { }
89
- // didn't respond this poll — either mid-respawn (transient)
90
- // or crash-looping (persistent). either way: down.
91
- return {
92
- name: key,
93
- status: "down",
94
- pid: w.pid,
95
- version: "",
96
- lastSeen: lastSeen.get(key) ?? 0,
97
- };
98
- }),
99
- );
100
-
101
- if (names) {
102
- const activeNames = new Set(cluster.map(w => w.name));
103
- for (const name of names) {
104
- if (!activeNames.has(name)) {
105
- cluster.push({
106
- name,
107
- status: "reserved",
108
- version: "",
109
- lastSeen: null,
110
- });
111
- }
112
- }
113
- }
114
- const allOk = cluster.every((w) => w.status === "up" || w.status === "reserved");
115
-
116
- return Response.json(
117
- {
118
- ok: allOk,
119
- version: process.env.npm_package_version ?? "0.0.0",
120
- uptime: Math.floor((Date.now() - startedAt) / 1000),
121
- cluster,
122
- } satisfies HealthResponse,
123
- {
124
- status: allOk ? 200 : 503,
125
- headers: { "Cache-Control": "no-store" },
126
- },
127
- );
128
- }
129
-
130
- // ── worker context: report local status ──
131
- const extra = options.extend ? await options.extend() : {};
132
- return Response.json(
133
- {
134
- ok: true,
135
- version: process.env.npm_package_version ?? "0.0.0",
136
- uptime: Math.floor((Date.now() - startedAt) / 1000),
137
- instanceId: process.env.GRIMOIRE_WORKER_NAME,
138
- pid: process.pid,
139
- ...extra,
140
- } satisfies HealthResponse,
141
- {
142
- status: 200,
143
- headers: { "Cache-Control": "no-store" },
144
- },
145
- );
146
- },
147
- };
148
- }
1
+ import type { CoordinatorContext, GrimoirePlugin } from "@sigil-dev/grimoire";
2
+
3
+ export interface HealthPluginOptions {
4
+ /** Route to serve health at. Default: /health */
5
+ path?: string;
6
+ /** Extra fields to include in the response */
7
+ extend?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
8
+ /** By default the plugin pulls names from @sigil-dev/plugin-namepool, but you can specify overrides.*/
9
+ names?: string[];
10
+ }
11
+
12
+ export type WorkerHealthStatus = "up" | "down" | "reserved";
13
+
14
+ export interface WorkerHealth {
15
+ name: string;
16
+ status: WorkerHealthStatus;
17
+ pid?: number;
18
+ version: string;
19
+ lastSeen: number | null; // null = never existed
20
+ }
21
+ export interface HealthResponse {
22
+ ok: boolean;
23
+ version: string;
24
+ uptime: number;
25
+ instanceId?: string;
26
+ cluster?: WorkerHealth[];
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export function healthPlugin(
31
+ options: HealthPluginOptions = {},
32
+ ): GrimoirePlugin {
33
+ const healthPath = options.path ?? "/health";
34
+ const startedAt = Date.now();
35
+ let names: string[] | undefined = options.names;
36
+
37
+ // set by onCoordinatorStart — stays null in worker context
38
+ let coordinatorCtx: CoordinatorContext | null = null;
39
+
40
+ // last successful contact per worker — lets a status page show
41
+ // "up but hasn't heartbeated recently" vs a fresh respawn
42
+ const lastSeen = new Map<string, number>();
43
+
44
+ return {
45
+ name: "@sigil-dev/plugin-health",
46
+
47
+ onPluginsResolved(ctx) {
48
+ const pool = ctx.plugins.get("namepool") as any;
49
+ names ??= pool?.names;
50
+ },
51
+
52
+ onCoordinatorStart(ctx) {
53
+ coordinatorCtx = ctx;
54
+ for (const w of ctx.workers) {
55
+ lastSeen.set(w.name ?? `${w.mode}-${w.index}`, Date.now());
56
+ }
57
+ },
58
+
59
+ onWorkerReady(worker) {
60
+ lastSeen.set(worker.name ?? `${worker.mode}-${worker.index}`, Date.now());
61
+ },
62
+
63
+ async onRequest(req, next) {
64
+ const url = new URL(req.url);
65
+ if (url.pathname !== healthPath) return next();
66
+
67
+ // ── coordinator context: fan out to all workers ──
68
+ if (coordinatorCtx) {
69
+ const cluster: WorkerHealth[] = await Promise.all(
70
+ coordinatorCtx.workers.map(async (w): Promise<WorkerHealth> => {
71
+ const key = w.name ?? `${w.mode}-${w.index}`;
72
+ try {
73
+ const res = await fetch(`${w.internalUrl}${healthPath}`, {
74
+ signal: AbortSignal.timeout(2_000),
75
+ headers: { "X-Grimoire-Internal": coordinatorCtx!.secret },
76
+ });
77
+ if (res.ok) {
78
+ lastSeen.set(key, Date.now());
79
+ const data = (await res.json()) as HealthResponse;
80
+ return {
81
+ name: key,
82
+ status: "up",
83
+ pid: w.pid,
84
+ version: data.version ?? "",
85
+ lastSeen: lastSeen.get(key)!,
86
+ };
87
+ }
88
+ } catch {}
89
+ // didn't respond this poll — either mid-respawn (transient)
90
+ // or crash-looping (persistent). either way: down.
91
+ return {
92
+ name: key,
93
+ status: "down",
94
+ pid: w.pid,
95
+ version: "",
96
+ lastSeen: lastSeen.get(key) ?? 0,
97
+ };
98
+ }),
99
+ );
100
+
101
+ if (names) {
102
+ const activeNames = new Set(cluster.map((w) => w.name));
103
+ for (const name of names) {
104
+ if (!activeNames.has(name)) {
105
+ cluster.push({
106
+ name,
107
+ status: "reserved",
108
+ version: "",
109
+ lastSeen: null,
110
+ });
111
+ }
112
+ }
113
+ }
114
+ const allOk = cluster.every(
115
+ (w) => w.status === "up" || w.status === "reserved",
116
+ );
117
+
118
+ return Response.json(
119
+ {
120
+ ok: allOk,
121
+ version: process.env.npm_package_version ?? "0.0.0",
122
+ uptime: Math.floor((Date.now() - startedAt) / 1000),
123
+ cluster,
124
+ } satisfies HealthResponse,
125
+ {
126
+ status: allOk ? 200 : 503,
127
+ headers: { "Cache-Control": "no-store" },
128
+ },
129
+ );
130
+ }
131
+
132
+ // ── worker context: report local status ──
133
+ const extra = options.extend ? await options.extend() : {};
134
+ return Response.json(
135
+ {
136
+ ok: true,
137
+ version: process.env.npm_package_version ?? "0.0.0",
138
+ uptime: Math.floor((Date.now() - startedAt) / 1000),
139
+ instanceId: process.env.GRIMOIRE_WORKER_NAME,
140
+ pid: process.pid,
141
+ ...extra,
142
+ } satisfies HealthResponse,
143
+ {
144
+ status: 200,
145
+ headers: { "Cache-Control": "no-store" },
146
+ },
147
+ );
148
+ },
149
+ };
150
+ }
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "dependencies": {
8
- "@sigil-dev/grimoire": "0.9.1"
8
+ "@sigil-dev/grimoire": "0.9.3"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@types/bun": "latest"
@@ -13,5 +13,5 @@
13
13
  "peerDependencies": {
14
14
  "typescript": "^5"
15
15
  },
16
- "version": "0.9.1"
16
+ "version": "0.9.3"
17
17
  }