@okay-e9g/hono-config 0.0.13 → 0.0.14
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/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/routes/health.ts +228 -0
- package/src/routes/index.ts +5 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: MIT
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Context } from 'hono';
|
|
6
|
+
import type { Env } from 'hono/types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Response returned by `createHealthRoute`.
|
|
10
|
+
*/
|
|
11
|
+
export type HealthResponse = {
|
|
12
|
+
checks: Record<
|
|
13
|
+
string,
|
|
14
|
+
| {
|
|
15
|
+
critical: boolean;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
status: 'healthy';
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
critical: boolean;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
message: string;
|
|
23
|
+
reason: 'failed' | 'timeout';
|
|
24
|
+
status: 'unhealthy';
|
|
25
|
+
}
|
|
26
|
+
>;
|
|
27
|
+
release: string | null;
|
|
28
|
+
status: 'degraded' | 'healthy' | 'unhealthy';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Options used to create a health route.
|
|
33
|
+
*
|
|
34
|
+
* A direct check is critical and uses the route-wide timeout, which defaults
|
|
35
|
+
* to 10 seconds. Use the descriptor form to mark a check as non-critical or
|
|
36
|
+
* override its timeout. Checks pass by returning normally and fail by throwing
|
|
37
|
+
* or rejecting.
|
|
38
|
+
*
|
|
39
|
+
* @typeParam T - Hono environment type available to health checks.
|
|
40
|
+
*/
|
|
41
|
+
export type HealthRouteOpts<T extends Env = Env> = {
|
|
42
|
+
checks?: Readonly<
|
|
43
|
+
Record<
|
|
44
|
+
string,
|
|
45
|
+
| ((c: Context<T>, signal: AbortSignal) => Promise<void> | void)
|
|
46
|
+
| {
|
|
47
|
+
check: (c: Context<T>, signal: AbortSignal) => Promise<void> | void;
|
|
48
|
+
critical?: boolean;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}
|
|
51
|
+
>
|
|
52
|
+
>;
|
|
53
|
+
release?: ((c: Context<T>) => string | null) | null | string;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const runCheck = async <T extends Env>(
|
|
58
|
+
c: Context<T>,
|
|
59
|
+
{
|
|
60
|
+
check,
|
|
61
|
+
critical,
|
|
62
|
+
name,
|
|
63
|
+
timeoutMs,
|
|
64
|
+
}: {
|
|
65
|
+
check: (c: Context<T>, signal: AbortSignal) => Promise<void> | void;
|
|
66
|
+
critical: boolean;
|
|
67
|
+
name: string;
|
|
68
|
+
timeoutMs: number;
|
|
69
|
+
},
|
|
70
|
+
): Promise<[string, HealthResponse['checks'][string]]> => {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const requestSignal = c.req.raw.signal;
|
|
73
|
+
const timeoutError = new Error(`Health check timed out after ${timeoutMs}ms`);
|
|
74
|
+
const start = performance.now();
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
let onRequestAbort: (() => void) | undefined;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await Promise.race([
|
|
80
|
+
Promise.resolve().then(() => check(c, controller.signal)),
|
|
81
|
+
new Promise<never>((_, reject) => {
|
|
82
|
+
const abort = (reason: unknown) => {
|
|
83
|
+
reject(reason);
|
|
84
|
+
controller.abort(reason);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
timer = setTimeout(() => abort(timeoutError), timeoutMs);
|
|
88
|
+
onRequestAbort = () => {
|
|
89
|
+
abort(requestSignal.reason ?? new Error('Health check request was aborted'));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (requestSignal.aborted) {
|
|
93
|
+
onRequestAbort();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
requestSignal.addEventListener('abort', onRequestAbort, { once: true });
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
return [
|
|
102
|
+
name,
|
|
103
|
+
{
|
|
104
|
+
critical,
|
|
105
|
+
durationMs: performance.now() - start,
|
|
106
|
+
status: 'healthy',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
name,
|
|
114
|
+
{
|
|
115
|
+
critical,
|
|
116
|
+
durationMs: performance.now() - start,
|
|
117
|
+
message: message === '' ? 'Health check failed' : message,
|
|
118
|
+
reason: error === timeoutError ? 'timeout' : 'failed',
|
|
119
|
+
status: 'unhealthy',
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
} finally {
|
|
123
|
+
if (timer !== undefined) {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (onRequestAbort !== undefined) {
|
|
128
|
+
requestSignal.removeEventListener('abort', onRequestAbort);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates a reusable Hono route for liveness and readiness checks.
|
|
135
|
+
*
|
|
136
|
+
* Checks run concurrently. A failure in a critical check produces an
|
|
137
|
+
* `unhealthy` response with HTTP 503, while failures limited to non-critical
|
|
138
|
+
* checks produce a `degraded` response with HTTP 200. With no checks, the
|
|
139
|
+
* route produces a healthy liveness response.
|
|
140
|
+
*
|
|
141
|
+
* Thrown check messages are included in the response. Protect detailed health
|
|
142
|
+
* routes with appropriate authentication or network restrictions when those
|
|
143
|
+
* messages may contain sensitive information.
|
|
144
|
+
*
|
|
145
|
+
* @typeParam T - Hono environment type available to health checks.
|
|
146
|
+
* @param opts - Checks, release metadata, and timeout configuration.
|
|
147
|
+
* @returns A terminal Hono route handler.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* import { Hono } from 'hono';
|
|
152
|
+
* import { createHealthRoute } from '@okay-e9g/hono-config';
|
|
153
|
+
*
|
|
154
|
+
* type AppEnv = {
|
|
155
|
+
* Bindings: {
|
|
156
|
+
* CACHE: KVNamespace;
|
|
157
|
+
* DB: D1Database;
|
|
158
|
+
* };
|
|
159
|
+
* };
|
|
160
|
+
*
|
|
161
|
+
* const app = new Hono<AppEnv>();
|
|
162
|
+
*
|
|
163
|
+
* app.get('/-/health', createHealthRoute<AppEnv>({
|
|
164
|
+
* checks: {
|
|
165
|
+
* database: async (c) => {
|
|
166
|
+
* await c.env.DB.prepare('SELECT 1').first();
|
|
167
|
+
* },
|
|
168
|
+
* cache: {
|
|
169
|
+
* check: async (c) => {
|
|
170
|
+
* await c.env.CACHE.get('__healthcheck__');
|
|
171
|
+
* },
|
|
172
|
+
* critical: false,
|
|
173
|
+
* timeoutMs: 2_000,
|
|
174
|
+
* },
|
|
175
|
+
* },
|
|
176
|
+
* }));
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export const createHealthRoute = <T extends Env = Env>({
|
|
180
|
+
checks = {},
|
|
181
|
+
release = null,
|
|
182
|
+
timeoutMs = 10_000,
|
|
183
|
+
}: HealthRouteOpts<T> = {}) => {
|
|
184
|
+
const preparedChecks = Object.entries(checks).map(([name, definition]) => {
|
|
185
|
+
if (typeof definition === 'function') {
|
|
186
|
+
return {
|
|
187
|
+
check: definition,
|
|
188
|
+
critical: true,
|
|
189
|
+
name,
|
|
190
|
+
timeoutMs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (definition === null || typeof definition.check !== 'function') {
|
|
195
|
+
throw new TypeError(`Health check "${name}" must be a function or check descriptor.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
check: definition.check,
|
|
200
|
+
critical: definition.critical ?? true,
|
|
201
|
+
name,
|
|
202
|
+
timeoutMs: definition.timeoutMs ?? timeoutMs,
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return async (c: Context<T>) => {
|
|
207
|
+
const checksByName = Object.fromEntries(
|
|
208
|
+
await Promise.all(preparedChecks.map((check) => runCheck(c, check))),
|
|
209
|
+
) as HealthResponse['checks'];
|
|
210
|
+
const failedChecks = Object.values(checksByName).filter(({ status }) => status === 'unhealthy');
|
|
211
|
+
const status: HealthResponse['status'] = failedChecks.some(({ critical }) => critical)
|
|
212
|
+
? 'unhealthy'
|
|
213
|
+
: failedChecks.length
|
|
214
|
+
? 'degraded'
|
|
215
|
+
: 'healthy';
|
|
216
|
+
|
|
217
|
+
c.header('Cache-Control', 'no-store');
|
|
218
|
+
|
|
219
|
+
return c.json(
|
|
220
|
+
{
|
|
221
|
+
checks: checksByName,
|
|
222
|
+
release: typeof release === 'function' ? release(c) : release,
|
|
223
|
+
status,
|
|
224
|
+
} satisfies HealthResponse,
|
|
225
|
+
status === 'unhealthy' ? 503 : 200,
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
};
|