All files / src/preview health-checker.ts

6.52% Statements 3/46
0% Branches 0/19
0% Functions 0/8
7.69% Lines 3/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103                    8x     8x     8x                                                                                                                                                                            
/**
 * Health Checker
 *
 * Polls service healthcheck endpoints through the Caddy proxy port
 * to verify that preview services are ready.
 */
 
import type { PreviewConfig, HealthResult } from './types.js';
import { createModuleLogger } from '../logger/index.js';
 
const log = createModuleLogger('preview:health');
 
/** Default timeout for health checks (120 seconds) */
const DEFAULT_TIMEOUT_MS = 120_000;
 
/** Default polling interval (3 seconds) */
const DEFAULT_INTERVAL_MS = 3_000;
 
/**
 * Wait for all non-internal services to become healthy by polling their
 * healthcheck endpoints through the Caddy proxy.
 *
 * @param port - The host port where Caddy is listening
 * @param config - Preview config with service definitions
 * @param timeoutMs - Maximum time to wait (default: 120s)
 * @returns Per-service health results
 */
export async function waitForHealthy(
  port: number,
  config: PreviewConfig,
  timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<HealthResult[]> {
  const services = Object.values(config.services).filter((svc) => {
    if (svc.internal) return false;
    if (!svc.healthcheck?.path) return false;
    return true;
  });
 
  if (services.length === 0) {
    log.info('no healthcheck endpoints configured, skipping health wait');
    return [];
  }
 
  const deadline = Date.now() + timeoutMs;
  const results = new Map<string, HealthResult>();
 
  // Initialize all as unhealthy
  for (const svc of services) {
    results.set(svc.name, { name: svc.name, healthy: false });
  }
 
  while (Date.now() < deadline) {
    const pending = services.filter((svc) => !results.get(svc.name)!.healthy);
    if (pending.length === 0) break;
 
    await Promise.all(
      pending.map(async (svc) => {
        const route = svc.route ?? '/';
        const healthPath = svc.healthcheck!.path;
        // Build URL through proxy route
        const basePath = route === '/' ? '' : route;
        const url = `http://127.0.0.1:${port}${basePath}${healthPath}`;
 
        try {
          const response = await fetch(url, {
            signal: AbortSignal.timeout(5000),
          });
          if (response.ok) {
            log.info({ service: svc.name, url }, 'service healthy');
            results.set(svc.name, { name: svc.name, healthy: true });
          }
        } catch {
          // Not ready yet
        }
      }),
    );
 
    const stillPending = services.filter((svc) => !results.get(svc.name)!.healthy);
    if (stillPending.length === 0) break;
 
    log.debug(
      { pending: stillPending.map((s) => s.name) },
      'waiting for services to become healthy',
    );
    await sleep(DEFAULT_INTERVAL_MS);
  }
 
  // Mark timed-out services
  for (const svc of services) {
    const result = results.get(svc.name)!;
    if (!result.healthy) {
      result.error = 'health check timed out';
      log.warn({ service: svc.name }, 'service health check timed out');
    }
  }
 
  return Array.from(results.values());
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}