86 lines
4.4 KiB
JavaScript
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 };
|
|
}
|