g1t/apps/status/src/probe.ts

94 lines3,318 bytesCodeBlame
1/**
2 * Running a part's check: each request it makes, all at once, with a short
3 * timeout. The answer's body is never read.
4 */
5import type { Check, Step } from "./components.ts";
6
7/** A check's raw outcome. */
8export type ProbeResult = {
9 ok: boolean;
10 /** How long it took, in milliseconds: the slowest of its requests. */
11 ms: number;
12 /** Why it failed, in a few words: "timed out", "HTTP 502". */
13 error?: string;
14};
15
16/** No request waits longer than this. */
17export const TIMEOUT_MS = 5000;
18
19export const USER_AGENT = "g1t-status (+https://status.g1t.sh)";
20
21type Fetch = (url: string, init: RequestInit) => Promise<Response>;
22
23class Timeout extends Error {}
24
25/** Runs `work`, timing it, and failing it after `timeoutMs`. */
26export async function timed(
27 work: (signal: AbortSignal) => Promise<true | string>,
28 timeoutMs = TIMEOUT_MS,
29 now: () => number = Date.now,
30): Promise<ProbeResult> {
31 const started = now();
32 const controller = new AbortController();
33 let timer: ReturnType<typeof setTimeout> | undefined;
34 const deadline = new Promise<never>((_, reject) => {
35 timer = setTimeout(() => {
36 controller.abort();
37 reject(new Timeout());
38 }, timeoutMs);
39 });
40 try {
41 const outcome = await Promise.race([work(controller.signal), deadline]);
42 const ms = now() - started;
43 return outcome === true ? { ok: true, ms } : { ok: false, ms, error: outcome };
44 } catch (error) {
45 const ms = now() - started;
46 return { ok: false, ms, error: error instanceof Timeout ? "timed out" : "could not connect" };
47 } finally {
48 clearTimeout(timer);
49 }
50}
51
52/** One request, answered with the status that means it works. */
53export function step(fetcher: Fetch, { url, headers = {}, expect }: Step, timeoutMs = TIMEOUT_MS): Promise<ProbeResult> {
54 return timed(async (signal) => {
55 const response = await fetcher(url, {
56 signal,
57 redirect: "manual",
58 headers: { "user-agent": USER_AGENT, "cache-control": "no-cache", ...headers },
59 });
60 await response.body?.cancel().catch(() => undefined);
61 const good = expect == null ? response.ok : response.status === expect;
62 return good || `HTTP ${response.status}`;
63 }, timeoutMs);
64}
65
66/** A check's requests together: it works when every one does, and takes as long as the slowest. */
67export function combine(results: ProbeResult[]): ProbeResult {
68 const ms = Math.max(0, ...results.map((r) => r.ms));
69 const failed = results.find((r) => !r.ok);
70 return failed ? { ok: false, ms, error: failed.error ?? "no answer" } : { ok: true, ms };
71}
72
73/** What runs a check. `billing` is null when there is no binding to it. */
74export type Probers = {
75 fetch: Fetch;
76 billing: (() => Promise<unknown>) | null;
77 timeoutMs?: number;
78};
79
80/** Runs one part's check. Null for a part with no check. */
81export async function runCheck(check: Check, probers: Probers): Promise<ProbeResult | null> {
82 const timeoutMs = probers.timeoutMs ?? TIMEOUT_MS;
83 switch (check.kind) {
84 case "http":
85 return combine(await Promise.all(check.steps.map((s) => step(probers.fetch, s, timeoutMs))));
86 case "billing": {
87 const billing = probers.billing;
88 if (!billing) return null;
89 return timed(async () => ((await billing()) ? true : "no price book"), timeoutMs);
90 }
91 case "none":
92 return null;
93 }
94}