@checkstack/healthcheck-http-backend 0.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # @checkstack/healthcheck-http-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/healthcheck-common@0.0.2
12
+
13
+ ## 0.0.3
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [b4eb432]
18
+ - Updated dependencies [a65e002]
19
+ - @checkstack/backend-api@1.1.0
20
+ - @checkstack/common@0.2.0
21
+ - @checkstack/healthcheck-common@0.1.1
22
+
23
+ ## 0.0.2
24
+
25
+ ### Patch Changes
26
+
27
+ - 81f3f85: ## Breaking: Unified Versioned<T> Architecture
28
+
29
+ Refactored the versioning system to use a unified `Versioned<T>` class instead of separate `VersionedSchema`, `VersionedData`, and `VersionedConfig` types.
30
+
31
+ ### Breaking Changes
32
+
33
+ - **`VersionedSchema<T>`** is replaced by `Versioned<T>` class
34
+ - **`VersionedData<T>`** is replaced by `VersionedRecord<T>` interface
35
+ - **`VersionedConfig<T>`** is replaced by `VersionedPluginRecord<T>` interface
36
+ - **`ConfigMigration<F, T>`** is replaced by `Migration<F, T>` interface
37
+ - **`MigrationChain<T>`** is removed (use `Migration<unknown, unknown>[]`)
38
+ - **`migrateVersionedData()`** is removed (use `versioned.parse()`)
39
+ - **`ConfigMigrationRunner`** is removed (migrations are internal to Versioned)
40
+
41
+ ### Migration Guide
42
+
43
+ Before:
44
+
45
+ ```typescript
46
+ const strategy: HealthCheckStrategy = {
47
+ config: {
48
+ version: 1,
49
+ schema: mySchema,
50
+ migrations: [],
51
+ },
52
+ };
53
+ const data = await migrateVersionedData(stored, 1, migrations);
54
+ ```
55
+
56
+ After:
57
+
58
+ ```typescript
59
+ const strategy: HealthCheckStrategy = {
60
+ config: new Versioned({
61
+ version: 1,
62
+ schema: mySchema,
63
+ migrations: [],
64
+ }),
65
+ };
66
+ const data = await strategy.config.parse(stored);
67
+ ```
68
+
69
+ - Updated dependencies [ffc28f6]
70
+ - Updated dependencies [4dd644d]
71
+ - Updated dependencies [71275dd]
72
+ - Updated dependencies [ae19ff6]
73
+ - Updated dependencies [0babb9c]
74
+ - Updated dependencies [b55fae6]
75
+ - Updated dependencies [b354ab3]
76
+ - Updated dependencies [81f3f85]
77
+ - @checkstack/common@0.1.0
78
+ - @checkstack/backend-api@1.0.0
79
+ - @checkstack/healthcheck-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@checkstack/healthcheck-http-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/backend-api": "workspace:*",
13
+ "@checkstack/healthcheck-common": "workspace:*",
14
+ "jsonpath-plus": "^10.3.0",
15
+ "@checkstack/common": "workspace:*"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.0.0",
19
+ "drizzle-kit": "^0.31.8",
20
+ "typescript": "^5.0.0",
21
+ "@checkstack/tsconfig": "workspace:*",
22
+ "@checkstack/scripts": "workspace:*"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import { HttpHealthCheckStrategy } from "./strategy";
6
+ import { pluginMetadata } from "./plugin-metadata";
7
+
8
+ export default createBackendPlugin({
9
+ metadata: pluginMetadata,
10
+ register(env) {
11
+ env.registerInit({
12
+ deps: {
13
+ healthCheckRegistry: coreServices.healthCheckRegistry,
14
+ logger: coreServices.logger,
15
+ },
16
+ init: async ({ healthCheckRegistry, logger }) => {
17
+ logger.debug("🔌 Registering HTTP Health Check Strategy...");
18
+ const strategy = new HttpHealthCheckStrategy();
19
+ healthCheckRegistry.register(strategy);
20
+ },
21
+ });
22
+ },
23
+ });
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the HTTP Health Check backend.
5
+ * This is the single source of truth for the plugin ID.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "healthcheck-http",
9
+ });
@@ -0,0 +1,398 @@
1
+ import { describe, expect, it, spyOn, afterEach } from "bun:test";
2
+ import { HttpHealthCheckStrategy, HttpHealthCheckConfig } from "./strategy";
3
+
4
+ describe("HttpHealthCheckStrategy", () => {
5
+ const strategy = new HttpHealthCheckStrategy();
6
+ const defaultConfig: HttpHealthCheckConfig = {
7
+ url: "https://example.com/api",
8
+ method: "GET",
9
+ timeout: 5000,
10
+ };
11
+
12
+ afterEach(() => {
13
+ spyOn(globalThis, "fetch").mockRestore();
14
+ });
15
+
16
+ describe("basic execution", () => {
17
+ it("should return healthy for successful response without assertions", async () => {
18
+ spyOn(globalThis, "fetch").mockResolvedValue(
19
+ new Response(null, { status: 200 })
20
+ );
21
+ const result = await strategy.execute(defaultConfig);
22
+ expect(result.status).toBe("healthy");
23
+ expect(result.metadata?.statusCode).toBe(200);
24
+ });
25
+
26
+ it("should return healthy for any status without status assertion", async () => {
27
+ spyOn(globalThis, "fetch").mockResolvedValue(
28
+ new Response(null, { status: 404 })
29
+ );
30
+ const result = await strategy.execute(defaultConfig);
31
+ // Without assertions, any response is "healthy" if reachable
32
+ expect(result.status).toBe("healthy");
33
+ expect(result.metadata?.statusCode).toBe(404);
34
+ });
35
+ });
36
+
37
+ describe("statusCode assertions", () => {
38
+ it("should pass statusCode equals assertion", async () => {
39
+ spyOn(globalThis, "fetch").mockResolvedValue(
40
+ new Response(null, { status: 200 })
41
+ );
42
+
43
+ const config: HttpHealthCheckConfig = {
44
+ ...defaultConfig,
45
+ assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
46
+ };
47
+
48
+ const result = await strategy.execute(config);
49
+ expect(result.status).toBe("healthy");
50
+ });
51
+
52
+ it("should fail statusCode equals assertion when mismatch", async () => {
53
+ spyOn(globalThis, "fetch").mockResolvedValue(
54
+ new Response(null, { status: 404 })
55
+ );
56
+
57
+ const config: HttpHealthCheckConfig = {
58
+ ...defaultConfig,
59
+ assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
60
+ };
61
+
62
+ const result = await strategy.execute(config);
63
+ expect(result.status).toBe("unhealthy");
64
+ expect(result.message).toContain("statusCode");
65
+ });
66
+
67
+ it("should pass statusCode lessThan assertion", async () => {
68
+ spyOn(globalThis, "fetch").mockResolvedValue(
69
+ new Response(null, { status: 201 })
70
+ );
71
+
72
+ const config: HttpHealthCheckConfig = {
73
+ ...defaultConfig,
74
+ assertions: [{ field: "statusCode", operator: "lessThan", value: 300 }],
75
+ };
76
+
77
+ const result = await strategy.execute(config);
78
+ expect(result.status).toBe("healthy");
79
+ });
80
+ });
81
+
82
+ describe("responseTime assertions", () => {
83
+ it("should pass responseTime assertion when fast", async () => {
84
+ spyOn(globalThis, "fetch").mockResolvedValue(
85
+ new Response(null, { status: 200 })
86
+ );
87
+
88
+ const config: HttpHealthCheckConfig = {
89
+ ...defaultConfig,
90
+ assertions: [
91
+ { field: "responseTime", operator: "lessThan", value: 10000 },
92
+ ],
93
+ };
94
+
95
+ const result = await strategy.execute(config);
96
+ expect(result.status).toBe("healthy");
97
+ });
98
+ });
99
+
100
+ describe("contentType assertions", () => {
101
+ it("should pass contentType contains assertion", async () => {
102
+ spyOn(globalThis, "fetch").mockResolvedValue(
103
+ new Response(JSON.stringify({}), {
104
+ status: 200,
105
+ headers: { "Content-Type": "application/json; charset=utf-8" },
106
+ })
107
+ );
108
+
109
+ const config: HttpHealthCheckConfig = {
110
+ ...defaultConfig,
111
+ assertions: [
112
+ { field: "contentType", operator: "contains", value: "json" },
113
+ ],
114
+ };
115
+
116
+ const result = await strategy.execute(config);
117
+ expect(result.status).toBe("healthy");
118
+ });
119
+ });
120
+
121
+ describe("header assertions", () => {
122
+ it("should pass header exists assertion", async () => {
123
+ spyOn(globalThis, "fetch").mockResolvedValue(
124
+ new Response(null, {
125
+ status: 200,
126
+ headers: { "X-Request-Id": "abc123" },
127
+ })
128
+ );
129
+
130
+ const config: HttpHealthCheckConfig = {
131
+ ...defaultConfig,
132
+ assertions: [
133
+ { field: "header", headerName: "X-Request-Id", operator: "exists" },
134
+ ],
135
+ };
136
+
137
+ const result = await strategy.execute(config);
138
+ expect(result.status).toBe("healthy");
139
+ });
140
+
141
+ it("should fail header exists assertion when missing", async () => {
142
+ spyOn(globalThis, "fetch").mockResolvedValue(
143
+ new Response(null, { status: 200 })
144
+ );
145
+
146
+ const config: HttpHealthCheckConfig = {
147
+ ...defaultConfig,
148
+ assertions: [
149
+ { field: "header", headerName: "X-Missing", operator: "exists" },
150
+ ],
151
+ };
152
+
153
+ const result = await strategy.execute(config);
154
+ expect(result.status).toBe("unhealthy");
155
+ expect(result.message).toContain("X-Missing");
156
+ });
157
+
158
+ it("should pass header equals assertion", async () => {
159
+ spyOn(globalThis, "fetch").mockResolvedValue(
160
+ new Response(null, {
161
+ status: 200,
162
+ headers: { "Cache-Control": "no-cache" },
163
+ })
164
+ );
165
+
166
+ const config: HttpHealthCheckConfig = {
167
+ ...defaultConfig,
168
+ assertions: [
169
+ {
170
+ field: "header",
171
+ headerName: "Cache-Control",
172
+ operator: "equals",
173
+ value: "no-cache",
174
+ },
175
+ ],
176
+ };
177
+
178
+ const result = await strategy.execute(config);
179
+ expect(result.status).toBe("healthy");
180
+ });
181
+ });
182
+
183
+ describe("jsonPath assertions", () => {
184
+ it("should pass jsonPath equals assertion", async () => {
185
+ spyOn(globalThis, "fetch").mockResolvedValue(
186
+ new Response(JSON.stringify({ status: "UP" }), {
187
+ status: 200,
188
+ headers: { "Content-Type": "application/json" },
189
+ })
190
+ );
191
+
192
+ const config: HttpHealthCheckConfig = {
193
+ ...defaultConfig,
194
+ assertions: [
195
+ {
196
+ field: "jsonPath",
197
+ path: "$.status",
198
+ operator: "equals",
199
+ value: "UP",
200
+ },
201
+ ],
202
+ };
203
+
204
+ const result = await strategy.execute(config);
205
+ expect(result.status).toBe("healthy");
206
+ });
207
+
208
+ it("should fail jsonPath equals assertion", async () => {
209
+ spyOn(globalThis, "fetch").mockResolvedValue(
210
+ new Response(JSON.stringify({ status: "DOWN" }), {
211
+ status: 200,
212
+ headers: { "Content-Type": "application/json" },
213
+ })
214
+ );
215
+
216
+ const config: HttpHealthCheckConfig = {
217
+ ...defaultConfig,
218
+ assertions: [
219
+ {
220
+ field: "jsonPath",
221
+ path: "$.status",
222
+ operator: "equals",
223
+ value: "UP",
224
+ },
225
+ ],
226
+ };
227
+
228
+ const result = await strategy.execute(config);
229
+ expect(result.status).toBe("unhealthy");
230
+ expect(result.message).toContain("Actual");
231
+ });
232
+
233
+ it("should pass jsonPath exists assertion", async () => {
234
+ spyOn(globalThis, "fetch").mockResolvedValue(
235
+ new Response(JSON.stringify({ version: "1.0.0" }), {
236
+ status: 200,
237
+ headers: { "Content-Type": "application/json" },
238
+ })
239
+ );
240
+
241
+ const config: HttpHealthCheckConfig = {
242
+ ...defaultConfig,
243
+ assertions: [
244
+ { field: "jsonPath", path: "$.version", operator: "exists" },
245
+ ],
246
+ };
247
+
248
+ const result = await strategy.execute(config);
249
+ expect(result.status).toBe("healthy");
250
+ });
251
+
252
+ it("should fail jsonPath exists assertion when path not found", async () => {
253
+ spyOn(globalThis, "fetch").mockResolvedValue(
254
+ new Response(JSON.stringify({ other: "data" }), {
255
+ status: 200,
256
+ headers: { "Content-Type": "application/json" },
257
+ })
258
+ );
259
+
260
+ const config: HttpHealthCheckConfig = {
261
+ ...defaultConfig,
262
+ assertions: [
263
+ { field: "jsonPath", path: "$.missing", operator: "exists" },
264
+ ],
265
+ };
266
+
267
+ const result = await strategy.execute(config);
268
+ expect(result.status).toBe("unhealthy");
269
+ });
270
+
271
+ it("should pass jsonPath contains assertion", async () => {
272
+ spyOn(globalThis, "fetch").mockResolvedValue(
273
+ new Response(JSON.stringify({ message: "Hello World" }), {
274
+ status: 200,
275
+ headers: { "Content-Type": "application/json" },
276
+ })
277
+ );
278
+
279
+ const config: HttpHealthCheckConfig = {
280
+ ...defaultConfig,
281
+ assertions: [
282
+ {
283
+ field: "jsonPath",
284
+ path: "$.message",
285
+ operator: "contains",
286
+ value: "Hello",
287
+ },
288
+ ],
289
+ };
290
+
291
+ const result = await strategy.execute(config);
292
+ expect(result.status).toBe("healthy");
293
+ });
294
+
295
+ it("should pass jsonPath matches (regex) assertion", async () => {
296
+ spyOn(globalThis, "fetch").mockResolvedValue(
297
+ new Response(JSON.stringify({ id: "abc-123" }), {
298
+ status: 200,
299
+ headers: { "Content-Type": "application/json" },
300
+ })
301
+ );
302
+
303
+ const config: HttpHealthCheckConfig = {
304
+ ...defaultConfig,
305
+ assertions: [
306
+ {
307
+ field: "jsonPath",
308
+ path: "$.id",
309
+ operator: "matches",
310
+ value: "^[a-z]{3}-\\d{3}$",
311
+ },
312
+ ],
313
+ };
314
+
315
+ const result = await strategy.execute(config);
316
+ expect(result.status).toBe("healthy");
317
+ });
318
+
319
+ it("should fail when response is not JSON but jsonPath assertions exist", async () => {
320
+ spyOn(globalThis, "fetch").mockResolvedValue(
321
+ new Response("Not JSON", {
322
+ status: 200,
323
+ headers: { "Content-Type": "text/plain" },
324
+ })
325
+ );
326
+
327
+ const config: HttpHealthCheckConfig = {
328
+ ...defaultConfig,
329
+ assertions: [{ field: "jsonPath", path: "$.id", operator: "exists" }],
330
+ };
331
+
332
+ const result = await strategy.execute(config);
333
+ expect(result.status).toBe("unhealthy");
334
+ expect(result.message).toContain("not valid JSON");
335
+ });
336
+ });
337
+
338
+ describe("combined assertions", () => {
339
+ it("should pass multiple assertion types", async () => {
340
+ spyOn(globalThis, "fetch").mockResolvedValue(
341
+ new Response(JSON.stringify({ healthy: true }), {
342
+ status: 200,
343
+ headers: {
344
+ "Content-Type": "application/json",
345
+ "X-Request-Id": "test-123",
346
+ },
347
+ })
348
+ );
349
+
350
+ const config: HttpHealthCheckConfig = {
351
+ ...defaultConfig,
352
+ assertions: [
353
+ { field: "statusCode", operator: "equals", value: 200 },
354
+ { field: "responseTime", operator: "lessThan", value: 10000 },
355
+ { field: "contentType", operator: "contains", value: "json" },
356
+ { field: "header", headerName: "X-Request-Id", operator: "exists" },
357
+ {
358
+ field: "jsonPath",
359
+ path: "$.healthy",
360
+ operator: "equals",
361
+ value: "true",
362
+ },
363
+ ],
364
+ };
365
+
366
+ const result = await strategy.execute(config);
367
+ expect(result.status).toBe("healthy");
368
+ expect(result.message).toContain("5 assertion");
369
+ });
370
+ });
371
+
372
+ describe("custom request options", () => {
373
+ it("should send custom headers with request", async () => {
374
+ let capturedHeaders: Record<string, string> | undefined;
375
+ spyOn(globalThis, "fetch").mockImplementation((async (
376
+ _url: RequestInfo | URL,
377
+ options?: RequestInit
378
+ ) => {
379
+ capturedHeaders = options?.headers as Record<string, string>;
380
+ return new Response(null, { status: 200 });
381
+ }) as unknown as typeof fetch);
382
+
383
+ const config: HttpHealthCheckConfig = {
384
+ ...defaultConfig,
385
+ headers: [
386
+ { name: "Authorization", value: "Bearer my-token" },
387
+ { name: "X-Custom-Header", value: "custom-value" },
388
+ ],
389
+ };
390
+
391
+ const result = await strategy.execute(config);
392
+ expect(result.status).toBe("healthy");
393
+ expect(capturedHeaders).toBeDefined();
394
+ expect(capturedHeaders?.["Authorization"]).toBe("Bearer my-token");
395
+ expect(capturedHeaders?.["X-Custom-Header"]).toBe("custom-value");
396
+ });
397
+ });
398
+ });
@@ -0,0 +1,513 @@
1
+ import { JSONPath } from "jsonpath-plus";
2
+ import {
3
+ HealthCheckStrategy,
4
+ HealthCheckResult,
5
+ HealthCheckRunForAggregation,
6
+ Versioned,
7
+ z,
8
+ numericField,
9
+ timeThresholdField,
10
+ stringField,
11
+ evaluateAssertions,
12
+ } from "@checkstack/backend-api";
13
+ import {
14
+ healthResultNumber,
15
+ healthResultString,
16
+ } from "@checkstack/healthcheck-common";
17
+
18
+ // ============================================================================
19
+ // SCHEMAS
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Header configuration for custom HTTP headers.
24
+ */
25
+ export const httpHeaderSchema = z.object({
26
+ name: z.string().min(1).describe("Header name"),
27
+ value: z.string().describe("Header value"),
28
+ });
29
+
30
+ /**
31
+ * JSONPath assertion for response body validation.
32
+ * Supports dynamic operators with runtime type coercion.
33
+ */
34
+ const jsonPathAssertionSchema = z.object({
35
+ field: z.literal("jsonPath"),
36
+ path: z
37
+ .string()
38
+ .describe("JSONPath expression (e.g. $.status, $.data[0].id)"),
39
+ operator: z.enum([
40
+ "equals",
41
+ "notEquals",
42
+ "contains",
43
+ "startsWith",
44
+ "endsWith",
45
+ "matches",
46
+ "exists",
47
+ "notExists",
48
+ "lessThan",
49
+ "lessThanOrEqual",
50
+ "greaterThan",
51
+ "greaterThanOrEqual",
52
+ ]),
53
+ value: z
54
+ .string()
55
+ .optional()
56
+ .describe("Expected value (not needed for exists/notExists)"),
57
+ });
58
+
59
+ /**
60
+ * Response header assertion schema.
61
+ * Check for specific header values in the response.
62
+ */
63
+ const headerAssertionSchema = z.object({
64
+ field: z.literal("header"),
65
+ headerName: z.string().describe("Response header name to check"),
66
+ operator: z.enum([
67
+ "equals",
68
+ "notEquals",
69
+ "contains",
70
+ "startsWith",
71
+ "endsWith",
72
+ "exists",
73
+ ]),
74
+ value: z.string().optional().describe("Expected header value"),
75
+ });
76
+
77
+ /**
78
+ * HTTP health check assertion schema using discriminated union.
79
+ *
80
+ * Assertions validate the result of a check:
81
+ * - statusCode: Validate HTTP status code
82
+ * - responseTime: Validate response latency
83
+ * - contentType: Validate Content-Type header
84
+ * - header: Validate any response header
85
+ * - jsonPath: Validate JSON response body content
86
+ */
87
+ const httpAssertionSchema = z.discriminatedUnion("field", [
88
+ numericField("statusCode", { min: 100, max: 599 }),
89
+ timeThresholdField("responseTime"),
90
+ stringField("contentType"),
91
+ headerAssertionSchema,
92
+ jsonPathAssertionSchema,
93
+ ]);
94
+
95
+ export type HttpAssertion = z.infer<typeof httpAssertionSchema>;
96
+
97
+ /**
98
+ * HTTP health check configuration schema.
99
+ *
100
+ * Config defines HOW to run the check (connection details, request setup).
101
+ * Assertions define WHAT to validate in the result.
102
+ */
103
+ export const httpHealthCheckConfigSchema = z.object({
104
+ url: z.string().url().describe("The full URL of the endpoint to check."),
105
+ method: z
106
+ .enum(["GET", "POST", "PUT", "DELETE", "HEAD"])
107
+ .default("GET")
108
+ .describe("The HTTP method to use for the request."),
109
+ headers: z
110
+ .array(httpHeaderSchema)
111
+ .optional()
112
+ .describe("Custom HTTP headers to send with the request."),
113
+ timeout: z
114
+ .number()
115
+ .min(100)
116
+ .default(5000)
117
+ .describe("Maximum time in milliseconds to wait for a response."),
118
+ body: z
119
+ .string()
120
+ .optional()
121
+ .describe(
122
+ "Optional request payload body (e.g. JSON for POST requests). [textarea]"
123
+ ),
124
+ assertions: z
125
+ .array(httpAssertionSchema)
126
+ .optional()
127
+ .describe("Validation conditions for the response."),
128
+ });
129
+
130
+ export type HttpHealthCheckConfig = z.infer<typeof httpHealthCheckConfigSchema>;
131
+
132
+ /** Per-run result metadata */
133
+ const httpResultMetadataSchema = z.object({
134
+ statusCode: healthResultNumber({
135
+ "x-chart-type": "counter",
136
+ "x-chart-label": "Status Code",
137
+ }).optional(),
138
+ contentType: healthResultString({
139
+ "x-chart-type": "text",
140
+ "x-chart-label": "Content Type",
141
+ }).optional(),
142
+ failedAssertion: httpAssertionSchema.optional(),
143
+ error: healthResultString({
144
+ "x-chart-type": "status",
145
+ "x-chart-label": "Error",
146
+ }).optional(),
147
+ });
148
+
149
+ export type HttpResultMetadata = z.infer<typeof httpResultMetadataSchema>;
150
+
151
+ /** Aggregated metadata for buckets */
152
+ const httpAggregatedMetadataSchema = z.object({
153
+ statusCodeCounts: z.record(z.string(), z.number()).meta({
154
+ "x-chart-type": "bar",
155
+ "x-chart-label": "Status Code Distribution",
156
+ }),
157
+ errorCount: healthResultNumber({
158
+ "x-chart-type": "counter",
159
+ "x-chart-label": "Errors",
160
+ }),
161
+ });
162
+
163
+ export type HttpAggregatedMetadata = z.infer<
164
+ typeof httpAggregatedMetadataSchema
165
+ >;
166
+
167
+ export class HttpHealthCheckStrategy
168
+ implements
169
+ HealthCheckStrategy<
170
+ HttpHealthCheckConfig,
171
+ HttpResultMetadata,
172
+ HttpAggregatedMetadata
173
+ >
174
+ {
175
+ id = "http";
176
+ displayName = "HTTP/HTTPS Health Check";
177
+ description = "HTTP endpoint health monitoring with flexible assertions";
178
+
179
+ config: Versioned<HttpHealthCheckConfig> = new Versioned({
180
+ version: 2, // Bumped for breaking change
181
+ schema: httpHealthCheckConfigSchema,
182
+ });
183
+
184
+ result: Versioned<HttpResultMetadata> = new Versioned({
185
+ version: 2,
186
+ schema: httpResultMetadataSchema,
187
+ });
188
+
189
+ aggregatedResult: Versioned<HttpAggregatedMetadata> = new Versioned({
190
+ version: 1,
191
+ schema: httpAggregatedMetadataSchema,
192
+ });
193
+
194
+ aggregateResult(
195
+ runs: HealthCheckRunForAggregation<HttpResultMetadata>[]
196
+ ): HttpAggregatedMetadata {
197
+ const statusCodeCounts: Record<string, number> = {};
198
+ let errorCount = 0;
199
+
200
+ for (const run of runs) {
201
+ if (run.metadata?.error) {
202
+ errorCount++;
203
+ }
204
+
205
+ if (run.metadata?.statusCode !== undefined) {
206
+ const key = String(run.metadata.statusCode);
207
+ statusCodeCounts[key] = (statusCodeCounts[key] || 0) + 1;
208
+ }
209
+ }
210
+
211
+ return { statusCodeCounts, errorCount };
212
+ }
213
+
214
+ async execute(
215
+ config: HttpHealthCheckConfig
216
+ ): Promise<HealthCheckResult<HttpResultMetadata>> {
217
+ // Validate and apply defaults from schema
218
+ const validatedConfig = this.config.validate(config);
219
+
220
+ const start = performance.now();
221
+ try {
222
+ // Convert headers array to Record for fetch API
223
+ const headersRecord: Record<string, string> = {};
224
+ if (validatedConfig.headers) {
225
+ for (const header of validatedConfig.headers) {
226
+ headersRecord[header.name] = header.value;
227
+ }
228
+ }
229
+
230
+ const response = await fetch(validatedConfig.url, {
231
+ method: validatedConfig.method,
232
+ headers: headersRecord,
233
+ body: validatedConfig.body,
234
+ signal: AbortSignal.timeout(validatedConfig.timeout),
235
+ });
236
+ const end = performance.now();
237
+ const latencyMs = Math.round(end - start);
238
+
239
+ // Collect response data for assertions
240
+ const statusCode = response.status;
241
+ const contentType = response.headers.get("content-type") || "";
242
+
243
+ // Collect response headers for header assertions
244
+ // Note: We get headers directly in the assertion loop, not pre-collected
245
+
246
+ // Build values object for standard assertions
247
+ const assertionValues: Record<string, unknown> = {
248
+ statusCode,
249
+ responseTime: latencyMs,
250
+ contentType,
251
+ };
252
+
253
+ // Separate assertions by type
254
+ const standardAssertions: Array<{
255
+ field: string;
256
+ operator: string;
257
+ value?: unknown;
258
+ }> = [];
259
+ const headerAssertions: Array<z.infer<typeof headerAssertionSchema>> = [];
260
+ const jsonPathAssertions: Array<z.infer<typeof jsonPathAssertionSchema>> =
261
+ [];
262
+
263
+ for (const assertion of validatedConfig.assertions || []) {
264
+ if (assertion.field === "header") {
265
+ headerAssertions.push(
266
+ assertion as z.infer<typeof headerAssertionSchema>
267
+ );
268
+ } else if (assertion.field === "jsonPath") {
269
+ jsonPathAssertions.push(
270
+ assertion as z.infer<typeof jsonPathAssertionSchema>
271
+ );
272
+ } else {
273
+ standardAssertions.push(assertion);
274
+ }
275
+ }
276
+
277
+ // Evaluate standard assertions (statusCode, responseTime, contentType)
278
+ const failedStandard = evaluateAssertions(
279
+ standardAssertions,
280
+ assertionValues
281
+ );
282
+ if (failedStandard) {
283
+ return {
284
+ status: "unhealthy",
285
+ latencyMs,
286
+ message: `Assertion failed: ${failedStandard.field} ${
287
+ failedStandard.operator
288
+ } ${"value" in failedStandard ? failedStandard.value : ""}`,
289
+ metadata: {
290
+ statusCode,
291
+ contentType,
292
+ failedAssertion: failedStandard as HttpAssertion,
293
+ },
294
+ };
295
+ }
296
+
297
+ // Evaluate header assertions
298
+ for (const headerAssertion of headerAssertions) {
299
+ // Get header value directly from the response
300
+ const headerValue =
301
+ response.headers.get(headerAssertion.headerName) ?? undefined;
302
+ const passed = this.evaluateHeaderAssertion(
303
+ headerAssertion.operator,
304
+ headerValue,
305
+ headerAssertion.value
306
+ );
307
+
308
+ if (!passed) {
309
+ return {
310
+ status: "unhealthy",
311
+ latencyMs,
312
+ message: `Header assertion failed: ${headerAssertion.headerName} ${
313
+ headerAssertion.operator
314
+ } ${headerAssertion.value || ""}. Actual: ${
315
+ headerValue ?? "(missing)"
316
+ }`,
317
+ metadata: {
318
+ statusCode,
319
+ contentType,
320
+ failedAssertion: headerAssertion,
321
+ },
322
+ };
323
+ }
324
+ }
325
+
326
+ // Evaluate JSONPath assertions (only if present)
327
+ if (jsonPathAssertions.length > 0) {
328
+ let responseData: unknown;
329
+
330
+ try {
331
+ if (contentType.includes("application/json")) {
332
+ responseData = await response.json();
333
+ } else {
334
+ const text = await response.text();
335
+ try {
336
+ responseData = JSON.parse(text);
337
+ } catch {
338
+ return {
339
+ status: "unhealthy",
340
+ latencyMs,
341
+ message:
342
+ "Response is not valid JSON, but JSONPath assertions are configured",
343
+ metadata: { statusCode, contentType },
344
+ };
345
+ }
346
+ }
347
+ } catch (error_: unknown) {
348
+ return {
349
+ status: "unhealthy",
350
+ latencyMs,
351
+ message: `Failed to parse response body: ${
352
+ (error_ as Error).message
353
+ }`,
354
+ metadata: { statusCode },
355
+ };
356
+ }
357
+
358
+ const extractPath = (path: string, json: unknown) =>
359
+ JSONPath({ path, json: json as object, wrap: false });
360
+
361
+ for (const jsonPathAssertion of jsonPathAssertions) {
362
+ const actualValue = extractPath(jsonPathAssertion.path, responseData);
363
+ const passed = this.evaluateJsonPathAssertion(
364
+ jsonPathAssertion.operator,
365
+ actualValue,
366
+ jsonPathAssertion.value
367
+ );
368
+
369
+ if (!passed) {
370
+ return {
371
+ status: "unhealthy",
372
+ latencyMs,
373
+ message: `JSONPath assertion failed: [${
374
+ jsonPathAssertion.path
375
+ }] ${jsonPathAssertion.operator} ${
376
+ jsonPathAssertion.value || ""
377
+ }. Actual: ${JSON.stringify(actualValue)}`,
378
+ metadata: {
379
+ statusCode,
380
+ contentType,
381
+ failedAssertion: jsonPathAssertion,
382
+ },
383
+ };
384
+ }
385
+ }
386
+ }
387
+
388
+ const assertionCount = validatedConfig.assertions?.length || 0;
389
+ return {
390
+ status: "healthy",
391
+ latencyMs,
392
+ message: `HTTP ${statusCode}${
393
+ assertionCount > 0 ? ` - passed ${assertionCount} assertion(s)` : ""
394
+ }`,
395
+ metadata: { statusCode, contentType },
396
+ };
397
+ } catch (error: unknown) {
398
+ const end = performance.now();
399
+ const isError = error instanceof Error;
400
+ return {
401
+ status: "unhealthy",
402
+ latencyMs: Math.round(end - start),
403
+ message: isError ? error.message : "Request failed",
404
+ metadata: { error: isError ? error.name : "UnknownError" },
405
+ };
406
+ }
407
+ }
408
+
409
+ private evaluateHeaderAssertion(
410
+ operator: string,
411
+ actual: string | undefined,
412
+ expected = ""
413
+ ): boolean {
414
+ if (operator === "exists") return actual !== undefined;
415
+
416
+ if (actual === undefined) return false;
417
+
418
+ switch (operator) {
419
+ case "equals": {
420
+ return actual === expected;
421
+ }
422
+ case "notEquals": {
423
+ return actual !== expected;
424
+ }
425
+ case "contains": {
426
+ return actual.includes(expected);
427
+ }
428
+ case "startsWith": {
429
+ return actual.startsWith(expected);
430
+ }
431
+ case "endsWith": {
432
+ return actual.endsWith(expected);
433
+ }
434
+ default: {
435
+ return false;
436
+ }
437
+ }
438
+ }
439
+
440
+ private evaluateJsonPathAssertion(
441
+ operator: string,
442
+ actual: unknown,
443
+ expected: string | undefined
444
+ ): boolean {
445
+ // Existence checks
446
+
447
+ if (operator === "exists") return actual !== undefined && actual !== null;
448
+
449
+ if (operator === "notExists")
450
+ return actual === undefined || actual === null;
451
+
452
+ // Numeric operators
453
+ if (
454
+ [
455
+ "lessThan",
456
+ "lessThanOrEqual",
457
+ "greaterThan",
458
+ "greaterThanOrEqual",
459
+ ].includes(operator)
460
+ ) {
461
+ const numActual = Number(actual);
462
+ const numExpected = Number(expected);
463
+ if (Number.isNaN(numActual) || Number.isNaN(numExpected)) return false;
464
+
465
+ switch (operator) {
466
+ case "lessThan": {
467
+ return numActual < numExpected;
468
+ }
469
+ case "lessThanOrEqual": {
470
+ return numActual <= numExpected;
471
+ }
472
+ case "greaterThan": {
473
+ return numActual > numExpected;
474
+ }
475
+ case "greaterThanOrEqual": {
476
+ return numActual >= numExpected;
477
+ }
478
+ }
479
+ }
480
+
481
+ // String operators
482
+ const strActual = String(actual ?? "");
483
+ const strExpected = expected || "";
484
+
485
+ switch (operator) {
486
+ case "equals": {
487
+ return actual === expected || strActual === strExpected;
488
+ }
489
+ case "notEquals": {
490
+ return actual !== expected && strActual !== strExpected;
491
+ }
492
+ case "contains": {
493
+ return strActual.includes(strExpected);
494
+ }
495
+ case "startsWith": {
496
+ return strActual.startsWith(strExpected);
497
+ }
498
+ case "endsWith": {
499
+ return strActual.endsWith(strExpected);
500
+ }
501
+ case "matches": {
502
+ try {
503
+ return new RegExp(strExpected).test(strActual);
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+ default: {
509
+ return false;
510
+ }
511
+ }
512
+ }
513
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }