1
21import { createServer, type Server } from "node:http";
22
23export const APP_NAME = "factory-worker";
24export const APP_VERSION = "0.1.0";
25
26export interface WorkerHealthDeps {
27
28 pingRedis: () => Promise<boolean>;
29
30 checkQueue?: () => Promise<boolean>;
31
46 checkSearchIndexLag?: () => Promise<{ pending: number; lagEntries: number }>;
47
53 metricsBody?: () => Promise<string>;
54}
55
56export interface WorkerHealthResult {
57 status: "ready" | "not_ready";
58 checks: {
59 redis: boolean;
60 queue: boolean;
61 };
62}
63
64export interface SearchIndexLagResult {
65 metric: "search-index:lag";
66 stream: "factory:events";
67 group: "worker-search-index";
68
69 available: boolean;
70
71 pending: number;
72
73 lagEntries: number;
74
75 backlog: number;
76 timestamp: string;
77}
78
79
84export function createHealthServer(deps: WorkerHealthDeps): Server {
85 const startedAt = Date.now();
86
87 const timeout = (ms: number) =>
88 new Promise<never>((_, reject) =>
89 setTimeout(() => reject(new Error("health check timeout")), ms).unref()
90 );
91
92 const ready = async (): Promise<WorkerHealthResult> => {
93 const [redis, queue] = await Promise.all([
94 withTimeout(deps.pingRedis(), timeout(1000)).catch(() => false),
95 deps.checkQueue ? withTimeout(deps.checkQueue(), timeout(1000)).catch(() => false) : true
96 ]);
97 return {
98 status: redis && queue ? "ready" : "not_ready",
99 checks: { redis, queue }
100 };
101 };
102
103
113 const lag = async (): Promise<SearchIndexLagResult> => {
114 const base: SearchIndexLagResult = {
115 metric: "search-index:lag",
116 stream: "factory:events",
117 group: "worker-search-index",
118 available: false,
119 pending: 0,
120 lagEntries: 0,
121 backlog: 0,
122 timestamp: new Date().toISOString()
123 };
124 if (!deps.checkSearchIndexLag) return base;
125 try {
126 const { pending, lagEntries } = await withTimeout(deps.checkSearchIndexLag(), timeout(1500));
127 return { ...base, available: true, pending, lagEntries, backlog: pending + lagEntries };
128 } catch {
129 return base;
130 }
131 };
132
133 return createServer(async (req, res) => {
134 const url = (req.url ?? "").split("?")[0];
135 if (req.method !== "GET") {
136 res.writeHead(405, { "content-type": "application/json" });
137 res.end(JSON.stringify({ status: "method_not_allowed" }));
138 return;
139 }
140
141
142 if (url === "/api/v1/health" || url === "/api/v1/health/" || url === "/health" || url === "/health/") {
143 res.writeHead(200, { "content-type": "application/json" });
144 res.end(
145 JSON.stringify({
146 status: "ok",
147 app: APP_NAME,
148 version: APP_VERSION,
149 uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
150 timestamp: new Date().toISOString()
151 })
152 );
153 return;
154 }
155 if (url === "/health/ready") {
156 const result = await ready();
157 res.writeHead(result.status === "ready" ? 200 : 503, {
158 "content-type": "application/json"
159 });
160 res.end(JSON.stringify(result));
161 return;
162 }
163 if (url === "/health/search-index:lag") {
164 res.writeHead(200, { "content-type": "application/json" });
165 res.end(JSON.stringify(await lag()));
166 return;
167 }
168 if (url === "/metrics") {
169 const body = deps.metricsBody ? await deps.metricsBody().catch(() => "") : "";
170 if (!body) {
171 res.writeHead(204);
172 res.end();
173 return;
174 }
175 res.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8" });
176 res.end(body);
177 return;
178 }
179 res.writeHead(404, { "content-type": "application/json" });
180 res.end(JSON.stringify({ status: "not_found" }));
181 });
182}
183
184async function withTimeout<T>(p: Promise<T>, t: Promise<never>): Promise<T> {
185 return Promise.race([p, t]);
186}
187