@checkstack/healthcheck-tcp-backend 0.0.2 → 0.1.0

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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # @checkstack/healthcheck-tcp-backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
8
+
9
+ **Strategy Changes:**
10
+
11
+ - Replaced `execute()` with `createClient()` that returns a transport client
12
+ - Strategy configs now only contain connection parameters
13
+ - Collector configs handle what to do with the connection
14
+
15
+ **Built-in Collectors Added:**
16
+
17
+ - DNS: `LookupCollector` for hostname resolution
18
+ - gRPC: `HealthCollector` for gRPC health protocol
19
+ - HTTP: `RequestCollector` for HTTP requests
20
+ - MySQL: `QueryCollector` for database queries
21
+ - Ping: `PingCollector` for ICMP ping
22
+ - Postgres: `QueryCollector` for database queries
23
+ - Redis: `CommandCollector` for Redis commands
24
+ - Script: `ExecuteCollector` for script execution
25
+ - SSH: `CommandCollector` for SSH commands
26
+ - TCP: `BannerCollector` for TCP banner grabbing
27
+ - TLS: `CertificateCollector` for certificate inspection
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [f5b1f49]
32
+ - Updated dependencies [f5b1f49]
33
+ - Updated dependencies [f5b1f49]
34
+ - Updated dependencies [f5b1f49]
35
+ - @checkstack/backend-api@0.1.0
36
+ - @checkstack/healthcheck-common@0.1.0
37
+ - @checkstack/common@0.0.3
38
+
39
+ ## 0.0.3
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [cb82e4d]
44
+ - @checkstack/healthcheck-common@0.0.3
45
+
3
46
  ## 0.0.2
4
47
 
5
48
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-tcp-backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { BannerCollector, type BannerConfig } from "./banner-collector";
3
+ import type { TcpTransportClient } from "./transport-client";
4
+
5
+ describe("BannerCollector", () => {
6
+ const createMockClient = (
7
+ response: {
8
+ banner?: string;
9
+ connected?: boolean;
10
+ error?: string;
11
+ } = {}
12
+ ): TcpTransportClient => ({
13
+ exec: mock(() =>
14
+ Promise.resolve({
15
+ banner: response.banner,
16
+ connected: response.connected ?? true,
17
+ error: response.error,
18
+ })
19
+ ),
20
+ });
21
+
22
+ describe("execute", () => {
23
+ it("should read banner successfully", async () => {
24
+ const collector = new BannerCollector();
25
+ const client = createMockClient({ banner: "SSH-2.0-OpenSSH_8.9" });
26
+
27
+ const result = await collector.execute({
28
+ config: { timeout: 5000 },
29
+ client,
30
+ pluginId: "test",
31
+ });
32
+
33
+ expect(result.result.banner).toBe("SSH-2.0-OpenSSH_8.9");
34
+ expect(result.result.hasBanner).toBe(true);
35
+ expect(result.result.readTimeMs).toBeGreaterThanOrEqual(0);
36
+ });
37
+
38
+ it("should return hasBanner false when no banner", async () => {
39
+ const collector = new BannerCollector();
40
+ const client = createMockClient({ banner: undefined });
41
+
42
+ const result = await collector.execute({
43
+ config: { timeout: 5000 },
44
+ client,
45
+ pluginId: "test",
46
+ });
47
+
48
+ expect(result.result.hasBanner).toBe(false);
49
+ });
50
+
51
+ it("should pass correct parameters to client", async () => {
52
+ const collector = new BannerCollector();
53
+ const client = createMockClient();
54
+
55
+ await collector.execute({
56
+ config: { timeout: 3000 },
57
+ client,
58
+ pluginId: "test",
59
+ });
60
+
61
+ expect(client.exec).toHaveBeenCalledWith({
62
+ type: "read",
63
+ timeout: 3000,
64
+ });
65
+ });
66
+ });
67
+
68
+ describe("aggregateResult", () => {
69
+ it("should calculate average read time and banner rate", () => {
70
+ const collector = new BannerCollector();
71
+ const runs = [
72
+ {
73
+ id: "1",
74
+ status: "healthy" as const,
75
+ latencyMs: 10,
76
+ checkId: "c1",
77
+ timestamp: new Date(),
78
+ metadata: { banner: "SSH-2.0", hasBanner: true, readTimeMs: 50 },
79
+ },
80
+ {
81
+ id: "2",
82
+ status: "healthy" as const,
83
+ latencyMs: 15,
84
+ checkId: "c1",
85
+ timestamp: new Date(),
86
+ metadata: { hasBanner: false, readTimeMs: 100 },
87
+ },
88
+ ];
89
+
90
+ const aggregated = collector.aggregateResult(runs);
91
+
92
+ expect(aggregated.avgReadTimeMs).toBe(75);
93
+ expect(aggregated.bannerRate).toBe(50);
94
+ });
95
+ });
96
+
97
+ describe("metadata", () => {
98
+ it("should have correct static properties", () => {
99
+ const collector = new BannerCollector();
100
+
101
+ expect(collector.id).toBe("banner");
102
+ expect(collector.displayName).toBe("TCP Banner");
103
+ expect(collector.allowMultiple).toBe(false);
104
+ expect(collector.supportedPlugins).toHaveLength(1);
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,149 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import {
9
+ healthResultNumber,
10
+ healthResultString,
11
+ healthResultBoolean,
12
+ } from "@checkstack/healthcheck-common";
13
+ import { pluginMetadata } from "./plugin-metadata";
14
+ import type { TcpTransportClient } from "./transport-client";
15
+
16
+ // ============================================================================
17
+ // CONFIGURATION SCHEMA
18
+ // ============================================================================
19
+
20
+ const bannerConfigSchema = z.object({
21
+ timeout: z
22
+ .number()
23
+ .min(100)
24
+ .default(5000)
25
+ .describe("Timeout for banner read in milliseconds"),
26
+ });
27
+
28
+ export type BannerConfig = z.infer<typeof bannerConfigSchema>;
29
+
30
+ // ============================================================================
31
+ // RESULT SCHEMAS
32
+ // ============================================================================
33
+
34
+ const bannerResultSchema = z.object({
35
+ banner: healthResultString({
36
+ "x-chart-type": "text",
37
+ "x-chart-label": "Banner",
38
+ }).optional(),
39
+ hasBanner: healthResultBoolean({
40
+ "x-chart-type": "boolean",
41
+ "x-chart-label": "Has Banner",
42
+ }),
43
+ readTimeMs: healthResultNumber({
44
+ "x-chart-type": "line",
45
+ "x-chart-label": "Read Time",
46
+ "x-chart-unit": "ms",
47
+ }),
48
+ });
49
+
50
+ export type BannerResult = z.infer<typeof bannerResultSchema>;
51
+
52
+ const bannerAggregatedSchema = z.object({
53
+ avgReadTimeMs: healthResultNumber({
54
+ "x-chart-type": "line",
55
+ "x-chart-label": "Avg Read Time",
56
+ "x-chart-unit": "ms",
57
+ }),
58
+ bannerRate: healthResultNumber({
59
+ "x-chart-type": "gauge",
60
+ "x-chart-label": "Banner Rate",
61
+ "x-chart-unit": "%",
62
+ }),
63
+ });
64
+
65
+ export type BannerAggregatedResult = z.infer<typeof bannerAggregatedSchema>;
66
+
67
+ // ============================================================================
68
+ // BANNER COLLECTOR
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Built-in TCP banner collector.
73
+ * Reads the initial banner/greeting from a TCP server.
74
+ */
75
+ export class BannerCollector
76
+ implements
77
+ CollectorStrategy<
78
+ TcpTransportClient,
79
+ BannerConfig,
80
+ BannerResult,
81
+ BannerAggregatedResult
82
+ >
83
+ {
84
+ id = "banner";
85
+ displayName = "TCP Banner";
86
+ description = "Read the initial banner/greeting from the server";
87
+
88
+ supportedPlugins = [pluginMetadata];
89
+
90
+ allowMultiple = false;
91
+
92
+ config = new Versioned({ version: 1, schema: bannerConfigSchema });
93
+ result = new Versioned({ version: 1, schema: bannerResultSchema });
94
+ aggregatedResult = new Versioned({
95
+ version: 1,
96
+ schema: bannerAggregatedSchema,
97
+ });
98
+
99
+ async execute({
100
+ config,
101
+ client,
102
+ }: {
103
+ config: BannerConfig;
104
+ client: TcpTransportClient;
105
+ pluginId: string;
106
+ }): Promise<CollectorResult<BannerResult>> {
107
+ const startTime = Date.now();
108
+
109
+ const response = await client.exec({
110
+ type: "read",
111
+ timeout: config.timeout,
112
+ });
113
+
114
+ const readTimeMs = Date.now() - startTime;
115
+
116
+ return {
117
+ result: {
118
+ banner: response.banner,
119
+ hasBanner: !!response.banner,
120
+ readTimeMs,
121
+ },
122
+ };
123
+ }
124
+
125
+ aggregateResult(
126
+ runs: HealthCheckRunForAggregation<BannerResult>[]
127
+ ): BannerAggregatedResult {
128
+ const times = runs
129
+ .map((r) => r.metadata?.readTimeMs)
130
+ .filter((v): v is number => typeof v === "number");
131
+
132
+ const hasBanners = runs
133
+ .map((r) => r.metadata?.hasBanner)
134
+ .filter((v): v is boolean => typeof v === "boolean");
135
+
136
+ const bannerCount = hasBanners.filter(Boolean).length;
137
+
138
+ return {
139
+ avgReadTimeMs:
140
+ times.length > 0
141
+ ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
142
+ : 0,
143
+ bannerRate:
144
+ hasBanners.length > 0
145
+ ? Math.round((bannerCount / hasBanners.length) * 100)
146
+ : 0,
147
+ };
148
+ }
149
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,7 @@
1
- import {
2
- createBackendPlugin,
3
- coreServices,
4
- } from "@checkstack/backend-api";
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
5
2
  import { TcpHealthCheckStrategy } from "./strategy";
6
3
  import { pluginMetadata } from "./plugin-metadata";
4
+ import { BannerCollector } from "./banner-collector";
7
5
 
8
6
  export default createBackendPlugin({
9
7
  metadata: pluginMetadata,
@@ -11,13 +9,17 @@ export default createBackendPlugin({
11
9
  env.registerInit({
12
10
  deps: {
13
11
  healthCheckRegistry: coreServices.healthCheckRegistry,
12
+ collectorRegistry: coreServices.collectorRegistry,
14
13
  logger: coreServices.logger,
15
14
  },
16
- init: async ({ healthCheckRegistry, logger }) => {
15
+ init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
17
16
  logger.debug("🔌 Registering TCP Health Check Strategy...");
18
17
  const strategy = new TcpHealthCheckStrategy();
19
18
  healthCheckRegistry.register(strategy);
19
+ collectorRegistry.register(new BannerCollector());
20
20
  },
21
21
  });
22
22
  },
23
23
  });
24
+
25
+ export { pluginMetadata } from "./plugin-metadata";
@@ -21,120 +21,72 @@ describe("TcpHealthCheckStrategy", () => {
21
21
  } as TcpSocket);
22
22
  };
23
23
 
24
- describe("execute", () => {
25
- it("should return healthy for successful connection", async () => {
24
+ describe("createClient", () => {
25
+ it("should return a connected client for successful connection", async () => {
26
26
  const strategy = new TcpHealthCheckStrategy(createMockSocket());
27
27
 
28
- const result = await strategy.execute({
28
+ const connectedClient = await strategy.createClient({
29
29
  host: "localhost",
30
30
  port: 80,
31
31
  timeout: 5000,
32
- readBanner: false,
33
32
  });
34
33
 
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
- );
34
+ expect(connectedClient.client).toBeDefined();
35
+ expect(connectedClient.client.exec).toBeDefined();
36
+ expect(connectedClient.close).toBeDefined();
44
37
 
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);
38
+ connectedClient.close();
55
39
  });
56
40
 
57
- it("should read banner when requested", async () => {
41
+ it("should throw for connection error", async () => {
58
42
  const strategy = new TcpHealthCheckStrategy(
59
- createMockSocket({ banner: "SSH-2.0-OpenSSH" })
43
+ createMockSocket({ connectError: new Error("Connection refused") })
60
44
  );
61
45
 
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");
46
+ await expect(
47
+ strategy.createClient({
48
+ host: "localhost",
49
+ port: 12345,
50
+ timeout: 5000,
51
+ })
52
+ ).rejects.toThrow("Connection refused");
71
53
  });
54
+ });
72
55
 
73
- it("should pass connectionTime assertion when below threshold", async () => {
56
+ describe("client.exec", () => {
57
+ it("should return connected status for connect action", async () => {
74
58
  const strategy = new TcpHealthCheckStrategy(createMockSocket());
75
-
76
- const result = await strategy.execute({
59
+ const connectedClient = await strategy.createClient({
77
60
  host: "localhost",
78
61
  port: 80,
79
62
  timeout: 5000,
80
- readBanner: false,
81
- assertions: [
82
- { field: "connectionTime", operator: "lessThan", value: 1000 },
83
- ],
84
63
  });
85
64
 
86
- expect(result.status).toBe("healthy");
87
- });
65
+ const result = await connectedClient.client.exec({ type: "connect" });
88
66
 
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
- );
67
+ expect(result.connected).toBe(true);
93
68
 
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");
69
+ connectedClient.close();
103
70
  });
104
71
 
105
- it("should fail banner assertion when not matching", async () => {
72
+ it("should read banner with read action", async () => {
106
73
  const strategy = new TcpHealthCheckStrategy(
107
- createMockSocket({ banner: "HTTP/1.1 200 OK" })
74
+ createMockSocket({ banner: "SSH-2.0-OpenSSH" })
108
75
  );
109
-
110
- const result = await strategy.execute({
76
+ const connectedClient = await strategy.createClient({
111
77
  host: "localhost",
112
- port: 80,
78
+ port: 22,
113
79
  timeout: 5000,
114
- readBanner: true,
115
- assertions: [{ field: "banner", operator: "contains", value: "SSH" }],
116
80
  });
117
81
 
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,
82
+ const result = await connectedClient.client.exec({
83
+ type: "read",
84
+ timeout: 1000,
135
85
  });
136
86
 
137
- expect(closeMock).toHaveBeenCalled();
87
+ expect(result.banner).toBe("SSH-2.0-OpenSSH");
88
+
89
+ connectedClient.close();
138
90
  });
139
91
  });
140
92
 
package/src/strategy.ts CHANGED
@@ -1,35 +1,28 @@
1
1
  import {
2
2
  HealthCheckStrategy,
3
- HealthCheckResult,
4
3
  HealthCheckRunForAggregation,
5
4
  Versioned,
6
5
  z,
7
- timeThresholdField,
8
- stringField,
9
- evaluateAssertions,
6
+ type ConnectedClient,
10
7
  } from "@checkstack/backend-api";
11
8
  import {
12
9
  healthResultBoolean,
13
10
  healthResultNumber,
14
11
  healthResultString,
15
12
  } from "@checkstack/healthcheck-common";
13
+ import type {
14
+ TcpTransportClient,
15
+ TcpConnectRequest,
16
+ TcpConnectResult,
17
+ } from "./transport-client";
16
18
 
17
19
  // ============================================================================
18
20
  // SCHEMAS
19
21
  // ============================================================================
20
22
 
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
23
  /**
32
24
  * Configuration schema for TCP health checks.
25
+ * Connection-only parameters - action params moved to BannerCollector.
33
26
  */
34
27
  export const tcpConfigSchema = z.object({
35
28
  host: z.string().describe("Hostname or IP address"),
@@ -39,18 +32,18 @@ export const tcpConfigSchema = z.object({
39
32
  .min(100)
40
33
  .default(5000)
41
34
  .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
35
  });
51
36
 
52
37
  export type TcpConfig = z.infer<typeof tcpConfigSchema>;
53
38
 
39
+ // Legacy config type for migrations
40
+ interface TcpConfigV1 {
41
+ host: string;
42
+ port: number;
43
+ timeout: number;
44
+ readBanner: boolean;
45
+ }
46
+
54
47
  /**
55
48
  * Per-run result metadata.
56
49
  */
@@ -68,14 +61,13 @@ const tcpResultSchema = z.object({
68
61
  "x-chart-type": "text",
69
62
  "x-chart-label": "Banner",
70
63
  }).optional(),
71
- failedAssertion: tcpAssertionSchema.optional(),
72
64
  error: healthResultString({
73
65
  "x-chart-type": "status",
74
66
  "x-chart-label": "Error",
75
67
  }).optional(),
76
68
  });
77
69
 
78
- export type TcpResult = z.infer<typeof tcpResultSchema>;
70
+ type TcpResult = z.infer<typeof tcpResultSchema>;
79
71
 
80
72
  /**
81
73
  * Aggregated metadata for buckets.
@@ -97,7 +89,7 @@ const tcpAggregatedSchema = z.object({
97
89
  }),
98
90
  });
99
91
 
100
- export type TcpAggregatedResult = z.infer<typeof tcpAggregatedSchema>;
92
+ type TcpAggregatedResult = z.infer<typeof tcpAggregatedSchema>;
101
93
 
102
94
  // ============================================================================
103
95
  // SOCKET INTERFACE (for testability)
@@ -113,8 +105,8 @@ export type SocketFactory = () => TcpSocket;
113
105
 
114
106
  // Default factory using Bun.connect
115
107
  const defaultSocketFactory: SocketFactory = () => {
116
- let socket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
117
- let receivedData = "";
108
+ let connectedSocket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
109
+ let dataBuffer = "";
118
110
 
119
111
  return {
120
112
  async connect(options: { host: string; port: number }): Promise<void> {
@@ -124,11 +116,11 @@ const defaultSocketFactory: SocketFactory = () => {
124
116
  port: options.port,
125
117
  socket: {
126
118
  open(sock) {
127
- socket = sock;
119
+ connectedSocket = sock;
128
120
  resolve();
129
121
  },
130
122
  data(_sock, data) {
131
- receivedData += new TextDecoder().decode(data);
123
+ dataBuffer += data.toString();
132
124
  },
133
125
  error(_sock, error) {
134
126
  reject(error);
@@ -137,23 +129,27 @@ const defaultSocketFactory: SocketFactory = () => {
137
129
  // Connection closed
138
130
  },
139
131
  },
140
- }).catch(reject);
132
+ });
141
133
  });
142
134
  },
143
135
  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;
136
+ return new Promise((resolve) => {
137
+ const start = Date.now();
138
+ const check = () => {
139
+ if (dataBuffer.length > 0) {
140
+ resolve(dataBuffer);
141
+ } else if (Date.now() - start > timeout) {
142
+ // eslint-disable-next-line unicorn/no-useless-undefined
143
+ resolve(undefined);
144
+ } else {
145
+ setTimeout(check, 50);
146
+ }
147
+ };
148
+ check();
149
+ });
154
150
  },
155
151
  close(): void {
156
- socket?.end();
152
+ connectedSocket?.end();
157
153
  },
158
154
  };
159
155
  };
@@ -163,7 +159,13 @@ const defaultSocketFactory: SocketFactory = () => {
163
159
  // ============================================================================
164
160
 
165
161
  export class TcpHealthCheckStrategy
166
- implements HealthCheckStrategy<TcpConfig, TcpResult, TcpAggregatedResult>
162
+ implements
163
+ HealthCheckStrategy<
164
+ TcpConfig,
165
+ TcpTransportClient,
166
+ TcpResult,
167
+ TcpAggregatedResult
168
+ >
167
169
  {
168
170
  id = "tcp";
169
171
  displayName = "TCP Health Check";
@@ -176,13 +178,33 @@ export class TcpHealthCheckStrategy
176
178
  }
177
179
 
178
180
  config: Versioned<TcpConfig> = new Versioned({
179
- version: 1,
181
+ version: 2,
180
182
  schema: tcpConfigSchema,
183
+ migrations: [
184
+ {
185
+ fromVersion: 1,
186
+ toVersion: 2,
187
+ description: "Remove readBanner (moved to BannerCollector)",
188
+ migrate: (data: TcpConfigV1): TcpConfig => ({
189
+ host: data.host,
190
+ port: data.port,
191
+ timeout: data.timeout,
192
+ }),
193
+ },
194
+ ],
181
195
  });
182
196
 
183
197
  result: Versioned<TcpResult> = new Versioned({
184
- version: 1,
198
+ version: 2,
185
199
  schema: tcpResultSchema,
200
+ migrations: [
201
+ {
202
+ fromVersion: 1,
203
+ toVersion: 2,
204
+ description: "Migrate to createClient pattern (no result changes)",
205
+ migrate: (data: unknown) => data,
206
+ },
207
+ ],
186
208
  });
187
209
 
188
210
  aggregatedResult: Versioned<TcpAggregatedResult> = new Versioned({
@@ -193,117 +215,59 @@ export class TcpHealthCheckStrategy
193
215
  aggregateResult(
194
216
  runs: HealthCheckRunForAggregation<TcpResult>[]
195
217
  ): TcpAggregatedResult {
196
- let totalConnectionTime = 0;
197
- let successCount = 0;
198
- let errorCount = 0;
199
- let validRuns = 0;
218
+ const validRuns = runs.filter((r) => r.metadata);
200
219
 
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
- }
220
+ if (validRuns.length === 0) {
221
+ return { avgConnectionTime: 0, successRate: 0, errorCount: 0 };
213
222
  }
214
223
 
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
- });
224
+ const connectionTimes = validRuns
225
+ .map((r) => r.metadata?.connectionTimeMs)
226
+ .filter((t): t is number => typeof t === "number");
236
227
 
237
- // Connect to host
238
- await Promise.race([
239
- socket.connect({
240
- host: validatedConfig.host,
241
- port: validatedConfig.port,
242
- }),
243
- timeoutPromise,
244
- ]);
228
+ const avgConnectionTime =
229
+ connectionTimes.length > 0
230
+ ? Math.round(
231
+ connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
232
+ )
233
+ : 0;
245
234
 
246
- const connectionTimeMs = Math.round(performance.now() - start);
235
+ const successCount = validRuns.filter(
236
+ (r) => r.metadata?.connected === true
237
+ ).length;
238
+ const successRate = Math.round((successCount / validRuns.length) * 100);
247
239
 
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
- }
240
+ const errorCount = validRuns.filter(
241
+ (r) => r.metadata?.error !== undefined
242
+ ).length;
257
243
 
258
- socket.close();
244
+ return { avgConnectionTime, successRate, errorCount };
245
+ }
259
246
 
260
- const result: Omit<TcpResult, "failedAssertion" | "error"> = {
261
- connected: true,
262
- connectionTimeMs,
263
- banner,
264
- };
247
+ async createClient(
248
+ config: TcpConfig
249
+ ): Promise<ConnectedClient<TcpTransportClient>> {
250
+ const validatedConfig = this.config.validate(config);
251
+ const socket = this.socketFactory();
265
252
 
266
- // Evaluate assertions using shared utility
267
- const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
268
- connectionTime: connectionTimeMs,
269
- banner: banner ?? "",
270
- });
253
+ await socket.connect({
254
+ host: validatedConfig.host,
255
+ port: validatedConfig.port,
256
+ });
271
257
 
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
- }
258
+ const client: TcpTransportClient = {
259
+ async exec(request: TcpConnectRequest): Promise<TcpConnectResult> {
260
+ if (request.type === "read" && request.timeout) {
261
+ const banner = await socket.read(request.timeout);
262
+ return { connected: true, banner };
263
+ }
264
+ return { connected: true };
265
+ },
266
+ };
282
267
 
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
- }
268
+ return {
269
+ client,
270
+ close: () => socket.close(),
271
+ };
308
272
  }
309
273
  }
@@ -0,0 +1,28 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * TCP connection request.
5
+ */
6
+ export interface TcpConnectRequest {
7
+ /** Action type */
8
+ type: "connect" | "read";
9
+ /** Timeout for banner read (optional) */
10
+ timeout?: number;
11
+ }
12
+
13
+ /**
14
+ * TCP connection result.
15
+ */
16
+ export interface TcpConnectResult {
17
+ connected: boolean;
18
+ banner?: string;
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * TCP transport client for connection checks.
24
+ */
25
+ export type TcpTransportClient = TransportClient<
26
+ TcpConnectRequest,
27
+ TcpConnectResult
28
+ >;