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.
104 lines
3.1 KiB
TypeScript
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));
|
|
}
|