Files
ridebus-status/server/probe.mjs
2026-10-13 02:24:39 +03:00

86 lines
4.4 KiB
JavaScript

const MAX_BODY_BYTES = 1024 * 1024;
async function bodyText(response) {
if (!response.body) return '';
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MAX_BODY_BYTES) { await reader.cancel(); throw new Error('body-limit'); }
chunks.push(value);
}
} finally { reader.releaseLock(); }
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
return new TextDecoder().decode(bytes);
}
export async function runProbe(probe, { timeoutMs, fetchImpl = fetch }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = performance.now();
let httpCode = null;
try {
let url = probe.url;
let headers = { 'User-Agent': 'RideBusStatus/1.0', ...probe.headers };
let response;
for (let redirects = 0; redirects <= 3; redirects++) {
response = await fetchImpl(url, { signal: controller.signal, redirect: 'manual', headers });
httpCode = response.status;
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get('location');
await response.body?.cancel();
if (!location || redirects === 3) return { ok: false, latencyMs: null, httpCode, reason: 'redirect' };
const next = new URL(location, url);
if (!['http:', 'https:'].includes(next.protocol) || next.username || next.password) return { ok: false, latencyMs: null, httpCode, reason: 'redirect' };
// Never forward secret/custom headers to another origin.
if (next.origin !== new URL(url).origin) headers = { 'User-Agent': 'RideBusStatus/1.0' };
url = next.toString();
}
if (httpCode !== 200) { await response.body?.cancel(); return { ok: false, latencyMs: null, httpCode, reason: 'http' }; }
const text = await bodyText(response);
if (probe.kind === 'html' && (!response.headers.get('content-type')?.toLowerCase().includes('text/html') || !/<html[\s>]/i.test(text))) {
return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
if (probe.kind === 'json') {
let json;
try { json = JSON.parse(text); } catch { return { ok: false, latencyMs: null, httpCode, reason: 'content' }; }
if (probe.expectedJson) {
const actual = probe.expectedJson.path.split('.').reduce((value, part) => value?.[part], json);
if (actual !== probe.expectedJson.value) return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
if (probe.requireDataArray) {
const data = Array.isArray(json) ? json : json?.data;
if (!Array.isArray(data) || !data.length) return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
if (probe.validateJson && !probe.validateJson(json)) return { ok: false, latencyMs: null, httpCode, reason: 'content' };
}
return { ok: true, latencyMs: Math.round(performance.now() - started), httpCode, reason: null };
} catch (error) {
return { ok: false, latencyMs: null, httpCode, reason: controller.signal.aborted ? 'timeout' : error.message === 'body-limit' ? 'content' : 'network' };
} finally { clearTimeout(timer); }
}
export async function checkService(service, options) {
if (!service.probes.length) return { ok: false, latencyMs: null, httpCode: null, reason: 'unconfigured' };
const results = await Promise.all(service.probes.map(probe => runProbe(probe, options)));
const failed = results.find(result => !result.ok);
return failed || { ok: true, latencyMs: Math.max(...results.map(result => result.latencyMs)), httpCode: results[0].httpCode, reason: null };
}
export function nextState(previous, result, config) {
if (['unconfigured', 'auth_required', 'browser_unavailable', 'not_run'].includes(result.reason)) return { status: 'unknown', failures: 0, successes: 0 };
const failures = result.ok ? 0 : (previous?.failures || 0) + 1;
const successes = result.ok ? (previous?.successes || 0) + 1 : 0;
let status;
if (!result.ok) status = previous?.status === 'down' || failures >= config.failureThreshold ? 'down' : 'degraded';
else if (previous?.status === 'down' && successes < config.recoveryThreshold) status = 'down';
else status = result.latencyMs > config.slowMs ? 'degraded' : 'operational';
return { status, failures, successes };
}