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') || !/]/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 }; }