@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 CHANGED
@@ -48,5 +48,5 @@
48
48
  "prepublishOnly": "bun run typecheck && bun test",
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
- "version": "0.0.13"
51
+ "version": "0.0.14"
52
52
  }
package/src/index.ts CHANGED
@@ -4,4 +4,5 @@
4
4
 
5
5
  export * from './helpers';
6
6
  export * from './middlewares';
7
+ export * from './routes';
7
8
  export * from './schemas';
@@ -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
+ };
@@ -0,0 +1,5 @@
1
+ /*
2
+ * SPDX-License-Identifier: MIT
3
+ */
4
+
5
+ export * from './health';