@checkstack/healthcheck-tcp-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,37 @@
1
+ # @checkstack/healthcheck-tcp-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
+ - Updated dependencies [ffc28f6]
28
+ - Updated dependencies [4dd644d]
29
+ - Updated dependencies [71275dd]
30
+ - Updated dependencies [ae19ff6]
31
+ - Updated dependencies [0babb9c]
32
+ - Updated dependencies [b55fae6]
33
+ - Updated dependencies [b354ab3]
34
+ - Updated dependencies [81f3f85]
35
+ - @checkstack/common@0.1.0
36
+ - @checkstack/backend-api@1.0.0
37
+ - @checkstack/healthcheck-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@checkstack/healthcheck-tcp-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/common": "workspace:*",
14
+ "@checkstack/healthcheck-common": "workspace:*"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "^1.0.0",
18
+ "typescript": "^5.0.0",
19
+ "@checkstack/tsconfig": "workspace:*",
20
+ "@checkstack/scripts": "workspace:*"
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import { TcpHealthCheckStrategy } 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 TCP Health Check Strategy...");
18
+ const strategy = new TcpHealthCheckStrategy();
19
+ healthCheckRegistry.register(strategy);
20
+ },
21
+ });
22
+ },
23
+ });
@@ -0,0 +1,8 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the TCP Health Check backend.
5
+ */
6
+ export const pluginMetadata = definePluginMetadata({
7
+ pluginId: "healthcheck-tcp",
8
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { TcpHealthCheckStrategy, TcpSocket, SocketFactory } from "./strategy";
3
+
4
+ describe("TcpHealthCheckStrategy", () => {
5
+ // Helper to create mock socket factory
6
+ const createMockSocket = (
7
+ config: {
8
+ connectError?: Error;
9
+ banner?: string;
10
+ } = {}
11
+ ): SocketFactory => {
12
+ return () =>
13
+ ({
14
+ connect: mock(() =>
15
+ config.connectError
16
+ ? Promise.reject(config.connectError)
17
+ : Promise.resolve()
18
+ ),
19
+ read: mock(() => Promise.resolve(config.banner ?? null)),
20
+ close: mock(() => {}),
21
+ } as TcpSocket);
22
+ };
23
+
24
+ describe("execute", () => {
25
+ it("should return healthy for successful connection", async () => {
26
+ const strategy = new TcpHealthCheckStrategy(createMockSocket());
27
+
28
+ const result = await strategy.execute({
29
+ host: "localhost",
30
+ port: 80,
31
+ timeout: 5000,
32
+ readBanner: false,
33
+ });
34
+
35
+ expect(result.status).toBe("healthy");
36
+ expect(result.metadata?.connected).toBe(true);
37
+ expect(result.metadata?.connectionTimeMs).toBeDefined();
38
+ });
39
+
40
+ it("should return unhealthy for connection error", async () => {
41
+ const strategy = new TcpHealthCheckStrategy(
42
+ createMockSocket({ connectError: new Error("Connection refused") })
43
+ );
44
+
45
+ const result = await strategy.execute({
46
+ host: "localhost",
47
+ port: 12345,
48
+ timeout: 5000,
49
+ readBanner: false,
50
+ });
51
+
52
+ expect(result.status).toBe("unhealthy");
53
+ expect(result.message).toContain("Connection refused");
54
+ expect(result.metadata?.connected).toBe(false);
55
+ });
56
+
57
+ it("should read banner when requested", async () => {
58
+ const strategy = new TcpHealthCheckStrategy(
59
+ createMockSocket({ banner: "SSH-2.0-OpenSSH" })
60
+ );
61
+
62
+ const result = await strategy.execute({
63
+ host: "localhost",
64
+ port: 22,
65
+ timeout: 5000,
66
+ readBanner: true,
67
+ });
68
+
69
+ expect(result.status).toBe("healthy");
70
+ expect(result.metadata?.banner).toBe("SSH-2.0-OpenSSH");
71
+ });
72
+
73
+ it("should pass connectionTime assertion when below threshold", async () => {
74
+ const strategy = new TcpHealthCheckStrategy(createMockSocket());
75
+
76
+ const result = await strategy.execute({
77
+ host: "localhost",
78
+ port: 80,
79
+ timeout: 5000,
80
+ readBanner: false,
81
+ assertions: [
82
+ { field: "connectionTime", operator: "lessThan", value: 1000 },
83
+ ],
84
+ });
85
+
86
+ expect(result.status).toBe("healthy");
87
+ });
88
+
89
+ it("should pass banner assertion with matching pattern", async () => {
90
+ const strategy = new TcpHealthCheckStrategy(
91
+ createMockSocket({ banner: "SSH-2.0-OpenSSH_8.9" })
92
+ );
93
+
94
+ const result = await strategy.execute({
95
+ host: "localhost",
96
+ port: 22,
97
+ timeout: 5000,
98
+ readBanner: true,
99
+ assertions: [{ field: "banner", operator: "contains", value: "SSH" }],
100
+ });
101
+
102
+ expect(result.status).toBe("healthy");
103
+ });
104
+
105
+ it("should fail banner assertion when not matching", async () => {
106
+ const strategy = new TcpHealthCheckStrategy(
107
+ createMockSocket({ banner: "HTTP/1.1 200 OK" })
108
+ );
109
+
110
+ const result = await strategy.execute({
111
+ host: "localhost",
112
+ port: 80,
113
+ timeout: 5000,
114
+ readBanner: true,
115
+ assertions: [{ field: "banner", operator: "contains", value: "SSH" }],
116
+ });
117
+
118
+ expect(result.status).toBe("unhealthy");
119
+ expect(result.message).toContain("Assertion failed");
120
+ });
121
+
122
+ it("should close socket after execution", async () => {
123
+ const closeMock = mock(() => {});
124
+ const strategy = new TcpHealthCheckStrategy(() => ({
125
+ connect: mock(() => Promise.resolve()),
126
+ read: mock(() => Promise.resolve(undefined)),
127
+ close: closeMock,
128
+ }));
129
+
130
+ await strategy.execute({
131
+ host: "localhost",
132
+ port: 80,
133
+ timeout: 5000,
134
+ readBanner: false,
135
+ });
136
+
137
+ expect(closeMock).toHaveBeenCalled();
138
+ });
139
+ });
140
+
141
+ describe("aggregateResult", () => {
142
+ it("should calculate averages correctly", () => {
143
+ const strategy = new TcpHealthCheckStrategy();
144
+ const runs = [
145
+ {
146
+ id: "1",
147
+ status: "healthy" as const,
148
+ latencyMs: 10,
149
+ checkId: "c1",
150
+ timestamp: new Date(),
151
+ metadata: {
152
+ connected: true,
153
+ connectionTimeMs: 10,
154
+ },
155
+ },
156
+ {
157
+ id: "2",
158
+ status: "healthy" as const,
159
+ latencyMs: 20,
160
+ checkId: "c1",
161
+ timestamp: new Date(),
162
+ metadata: {
163
+ connected: true,
164
+ connectionTimeMs: 20,
165
+ },
166
+ },
167
+ ];
168
+
169
+ const aggregated = strategy.aggregateResult(runs);
170
+
171
+ expect(aggregated.avgConnectionTime).toBe(15);
172
+ expect(aggregated.successRate).toBe(100);
173
+ expect(aggregated.errorCount).toBe(0);
174
+ });
175
+
176
+ it("should count errors and calculate success rate", () => {
177
+ const strategy = new TcpHealthCheckStrategy();
178
+ const runs = [
179
+ {
180
+ id: "1",
181
+ status: "healthy" as const,
182
+ latencyMs: 10,
183
+ checkId: "c1",
184
+ timestamp: new Date(),
185
+ metadata: {
186
+ connected: true,
187
+ connectionTimeMs: 10,
188
+ },
189
+ },
190
+ {
191
+ id: "2",
192
+ status: "unhealthy" as const,
193
+ latencyMs: 0,
194
+ checkId: "c1",
195
+ timestamp: new Date(),
196
+ metadata: {
197
+ connected: false,
198
+ connectionTimeMs: 0,
199
+ error: "Connection refused",
200
+ },
201
+ },
202
+ ];
203
+
204
+ const aggregated = strategy.aggregateResult(runs);
205
+
206
+ expect(aggregated.successRate).toBe(50);
207
+ expect(aggregated.errorCount).toBe(1);
208
+ });
209
+ });
210
+ });
@@ -0,0 +1,309 @@
1
+ import {
2
+ HealthCheckStrategy,
3
+ HealthCheckResult,
4
+ HealthCheckRunForAggregation,
5
+ Versioned,
6
+ z,
7
+ timeThresholdField,
8
+ stringField,
9
+ evaluateAssertions,
10
+ } from "@checkstack/backend-api";
11
+ import {
12
+ healthResultBoolean,
13
+ healthResultNumber,
14
+ healthResultString,
15
+ } from "@checkstack/healthcheck-common";
16
+
17
+ // ============================================================================
18
+ // SCHEMAS
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Assertion schema for TCP health checks using shared factories.
23
+ */
24
+ const tcpAssertionSchema = z.discriminatedUnion("field", [
25
+ timeThresholdField("connectionTime"),
26
+ stringField("banner"),
27
+ ]);
28
+
29
+ export type TcpAssertion = z.infer<typeof tcpAssertionSchema>;
30
+
31
+ /**
32
+ * Configuration schema for TCP health checks.
33
+ */
34
+ export const tcpConfigSchema = z.object({
35
+ host: z.string().describe("Hostname or IP address"),
36
+ port: z.number().int().min(1).max(65_535).describe("TCP port number"),
37
+ timeout: z
38
+ .number()
39
+ .min(100)
40
+ .default(5000)
41
+ .describe("Connection timeout in milliseconds"),
42
+ readBanner: z
43
+ .boolean()
44
+ .default(false)
45
+ .describe("Read initial banner/greeting from server"),
46
+ assertions: z
47
+ .array(tcpAssertionSchema)
48
+ .optional()
49
+ .describe("Validation conditions"),
50
+ });
51
+
52
+ export type TcpConfig = z.infer<typeof tcpConfigSchema>;
53
+
54
+ /**
55
+ * Per-run result metadata.
56
+ */
57
+ const tcpResultSchema = z.object({
58
+ connected: healthResultBoolean({
59
+ "x-chart-type": "boolean",
60
+ "x-chart-label": "Connected",
61
+ }),
62
+ connectionTimeMs: healthResultNumber({
63
+ "x-chart-type": "line",
64
+ "x-chart-label": "Connection Time",
65
+ "x-chart-unit": "ms",
66
+ }),
67
+ banner: healthResultString({
68
+ "x-chart-type": "text",
69
+ "x-chart-label": "Banner",
70
+ }).optional(),
71
+ failedAssertion: tcpAssertionSchema.optional(),
72
+ error: healthResultString({
73
+ "x-chart-type": "status",
74
+ "x-chart-label": "Error",
75
+ }).optional(),
76
+ });
77
+
78
+ export type TcpResult = z.infer<typeof tcpResultSchema>;
79
+
80
+ /**
81
+ * Aggregated metadata for buckets.
82
+ */
83
+ const tcpAggregatedSchema = z.object({
84
+ avgConnectionTime: healthResultNumber({
85
+ "x-chart-type": "line",
86
+ "x-chart-label": "Avg Connection Time",
87
+ "x-chart-unit": "ms",
88
+ }),
89
+ successRate: healthResultNumber({
90
+ "x-chart-type": "gauge",
91
+ "x-chart-label": "Success Rate",
92
+ "x-chart-unit": "%",
93
+ }),
94
+ errorCount: healthResultNumber({
95
+ "x-chart-type": "counter",
96
+ "x-chart-label": "Errors",
97
+ }),
98
+ });
99
+
100
+ export type TcpAggregatedResult = z.infer<typeof tcpAggregatedSchema>;
101
+
102
+ // ============================================================================
103
+ // SOCKET INTERFACE (for testability)
104
+ // ============================================================================
105
+
106
+ export interface TcpSocket {
107
+ connect(options: { host: string; port: number }): Promise<void>;
108
+ read(timeout: number): Promise<string | undefined>;
109
+ close(): void;
110
+ }
111
+
112
+ export type SocketFactory = () => TcpSocket;
113
+
114
+ // Default factory using Bun.connect
115
+ const defaultSocketFactory: SocketFactory = () => {
116
+ let socket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
117
+ let receivedData = "";
118
+
119
+ return {
120
+ async connect(options: { host: string; port: number }): Promise<void> {
121
+ return new Promise((resolve, reject) => {
122
+ Bun.connect({
123
+ hostname: options.host,
124
+ port: options.port,
125
+ socket: {
126
+ open(sock) {
127
+ socket = sock;
128
+ resolve();
129
+ },
130
+ data(_sock, data) {
131
+ receivedData += new TextDecoder().decode(data);
132
+ },
133
+ error(_sock, error) {
134
+ reject(error);
135
+ },
136
+ close() {
137
+ // Connection closed
138
+ },
139
+ },
140
+ }).catch(reject);
141
+ });
142
+ },
143
+ async read(timeout: number): Promise<string | undefined> {
144
+ const start = Date.now();
145
+ while (Date.now() - start < timeout) {
146
+ if (receivedData.length > 0) {
147
+ const data = receivedData;
148
+ receivedData = "";
149
+ return data;
150
+ }
151
+ await new Promise((r) => setTimeout(r, 50));
152
+ }
153
+ return receivedData.length > 0 ? receivedData : undefined;
154
+ },
155
+ close(): void {
156
+ socket?.end();
157
+ },
158
+ };
159
+ };
160
+
161
+ // ============================================================================
162
+ // STRATEGY
163
+ // ============================================================================
164
+
165
+ export class TcpHealthCheckStrategy
166
+ implements HealthCheckStrategy<TcpConfig, TcpResult, TcpAggregatedResult>
167
+ {
168
+ id = "tcp";
169
+ displayName = "TCP Health Check";
170
+ description = "TCP port connectivity check with optional banner grab";
171
+
172
+ private socketFactory: SocketFactory;
173
+
174
+ constructor(socketFactory: SocketFactory = defaultSocketFactory) {
175
+ this.socketFactory = socketFactory;
176
+ }
177
+
178
+ config: Versioned<TcpConfig> = new Versioned({
179
+ version: 1,
180
+ schema: tcpConfigSchema,
181
+ });
182
+
183
+ result: Versioned<TcpResult> = new Versioned({
184
+ version: 1,
185
+ schema: tcpResultSchema,
186
+ });
187
+
188
+ aggregatedResult: Versioned<TcpAggregatedResult> = new Versioned({
189
+ version: 1,
190
+ schema: tcpAggregatedSchema,
191
+ });
192
+
193
+ aggregateResult(
194
+ runs: HealthCheckRunForAggregation<TcpResult>[]
195
+ ): TcpAggregatedResult {
196
+ let totalConnectionTime = 0;
197
+ let successCount = 0;
198
+ let errorCount = 0;
199
+ let validRuns = 0;
200
+
201
+ for (const run of runs) {
202
+ if (run.metadata?.error) {
203
+ errorCount++;
204
+ continue;
205
+ }
206
+ if (run.status === "healthy") {
207
+ successCount++;
208
+ }
209
+ if (run.metadata) {
210
+ totalConnectionTime += run.metadata.connectionTimeMs;
211
+ validRuns++;
212
+ }
213
+ }
214
+
215
+ return {
216
+ avgConnectionTime: validRuns > 0 ? totalConnectionTime / validRuns : 0,
217
+ successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
218
+ errorCount,
219
+ };
220
+ }
221
+
222
+ async execute(config: TcpConfig): Promise<HealthCheckResult<TcpResult>> {
223
+ const validatedConfig = this.config.validate(config);
224
+ const start = performance.now();
225
+
226
+ const socket = this.socketFactory();
227
+
228
+ try {
229
+ // Set up timeout
230
+ const timeoutPromise = new Promise<never>((_, reject) => {
231
+ setTimeout(
232
+ () => reject(new Error("Connection timeout")),
233
+ validatedConfig.timeout
234
+ );
235
+ });
236
+
237
+ // Connect to host
238
+ await Promise.race([
239
+ socket.connect({
240
+ host: validatedConfig.host,
241
+ port: validatedConfig.port,
242
+ }),
243
+ timeoutPromise,
244
+ ]);
245
+
246
+ const connectionTimeMs = Math.round(performance.now() - start);
247
+
248
+ // Read banner if requested
249
+ let banner: string | undefined;
250
+ if (validatedConfig.readBanner) {
251
+ const bannerTimeout = Math.max(
252
+ 1000,
253
+ validatedConfig.timeout - connectionTimeMs
254
+ );
255
+ banner = (await socket.read(bannerTimeout)) ?? undefined;
256
+ }
257
+
258
+ socket.close();
259
+
260
+ const result: Omit<TcpResult, "failedAssertion" | "error"> = {
261
+ connected: true,
262
+ connectionTimeMs,
263
+ banner,
264
+ };
265
+
266
+ // Evaluate assertions using shared utility
267
+ const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
268
+ connectionTime: connectionTimeMs,
269
+ banner: banner ?? "",
270
+ });
271
+
272
+ if (failedAssertion) {
273
+ return {
274
+ status: "unhealthy",
275
+ latencyMs: connectionTimeMs,
276
+ message: `Assertion failed: ${failedAssertion.field} ${
277
+ failedAssertion.operator
278
+ }${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
279
+ metadata: { ...result, failedAssertion },
280
+ };
281
+ }
282
+
283
+ return {
284
+ status: "healthy",
285
+ latencyMs: connectionTimeMs,
286
+ message: `Connected to ${validatedConfig.host}:${
287
+ validatedConfig.port
288
+ } in ${connectionTimeMs}ms${
289
+ banner ? ` (banner: ${banner.slice(0, 50)}...)` : ""
290
+ }`,
291
+ metadata: result,
292
+ };
293
+ } catch (error: unknown) {
294
+ socket.close();
295
+ const end = performance.now();
296
+ const isError = error instanceof Error;
297
+ return {
298
+ status: "unhealthy",
299
+ latencyMs: Math.round(end - start),
300
+ message: isError ? error.message : "TCP connection failed",
301
+ metadata: {
302
+ connected: false,
303
+ connectionTimeMs: Math.round(end - start),
304
+ error: isError ? error.name : "UnknownError",
305
+ },
306
+ };
307
+ }
308
+ }
309
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }