Files
Codewalkers/apps/server/preview/health-checker.ts
Lukas May 1b8e496d39 fix: Switch preview gateway from path-prefix to subdomain routing
Path-prefix routing (`localhost:9100/<id>/`) broke SPAs because absolute
asset paths (`/assets/index.js`) didn't match the `handle_path /<id>/*`
route. Subdomain routing (`<id>.localhost:9100/`) resolves this since
all paths are relative to the root. Chrome/Firefox resolve *.localhost
to 127.0.0.1 natively — no DNS setup needed.
2026-03-05 22:38:00 +01:00

104 lines
3.1 KiB
TypeScript

/**
* Health Checker
*
* Polls service healthcheck endpoints through the gateway's subdomain-based routing
* 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 gateway's subdomain routing.
*
* @param previewId - The preview deployment ID (used as subdomain)
* @param gatewayPort - The gateway's host port
* @param config - Preview config with service definitions
* @param timeoutMs - Maximum time to wait (default: 120s)
* @returns Per-service health results
*/
export async function waitForHealthy(
previewId: string,
gatewayPort: 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;
const basePath = route === '/' ? '' : route;
const url = `http://${previewId}.localhost:${gatewayPort}${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));
}