import { Redis } from "@upstash/redis"; import { z } from "zod"; import { flyRegions, monitorRegionSchema, } from "@openstatus/db/src/schema/constants"; import type { Region } from "@openstatus/db/src/schema/constants"; import { continentDict, getRegionInfo, regionDict } from "@openstatus/regions"; export function latencyFormatter(value: number) { return `${new Intl.NumberFormat("us").format(value).toString()}ms`; } export function timestampFormatter(timestamp: number) { return new Date(timestamp).toUTCString(); // GMT format } export function continentFormatter(region: Region) { const continent = regionDict[region].continent; return continentDict[continent].code; } export function regionFormatter( region: string, type: "short" | "long" = "short", ) { const { code, flag, location } = getRegionInfo(region); if (type === "short") return `${code} ${flag}`; return `${location} ${flag}`; } export function getTotalLatency(timing: Timing) { const { dns, connection, tls, ttfb, transfer } = getTimingPhases(timing); return dns + connection + tls + ttfb + transfer; } export function getTimingPhases(timing: Timing) { const dns = timing.dnsDone - timing.dnsStart; const connection = timing.connectDone - timing.connectStart; const tls = timing.tlsHandshakeDone - timing.tlsHandshakeStart; const ttfb = timing.firstByteDone - timing.firstByteStart; const transfer = timing.transferDone - timing.transferStart; return { dns, connection, tls, ttfb, transfer, }; } export function getTimingPhasesWidth(timing: Timing) { const total = getTotalLatency(timing); const phases = getTimingPhases(timing); const dns = { preWidth: 0, width: (phases.dns / total) * 100 }; const connection = { preWidth: dns.preWidth + dns.width, width: (phases.connection / total) * 100, }; const tls = { preWidth: connection.preWidth + connection.width, width: (phases.tls / total) * 100, }; const ttfb = { preWidth: tls.preWidth + tls.width, width: (phases.ttfb / total) * 100, }; const transfer = { preWidth: ttfb.preWidth + ttfb.width, width: (phases.transfer / total) * 100, }; return { dns, connection, tls, ttfb, transfer, }; } export const timingSchema = z.object({ dnsStart: z.number(), dnsDone: z.number(), connectStart: z.number(), connectDone: z.number(), tlsHandshakeStart: z.number(), tlsHandshakeDone: z.number(), firstByteStart: z.number(), firstByteDone: z.number(), transferStart: z.number(), transferDone: z.number(), }); export const checkerSchema = z.object({ type: z.literal("http").prefault("http"), state: z.literal("success").prefault("success"), status: z.number(), latency: z.number(), headers: z.record(z.string(), z.string()), timestamp: z.number(), timing: timingSchema, body: z.string().optional().nullable(), }); export const cachedCheckerSchema = z.object({ url: z.string(), timestamp: z.number(), method: z.enum(["GET", "POST", "PUT", "DELETE"]).prefault("GET"), checks: checkerSchema.extend({ region: monitorRegionSchema }).array(), }); const errorRequest = z.object({ message: z.string(), state: z.literal("error").prefault("error"), }); export const regionCheckerSchema = checkerSchema.extend({ region: monitorRegionSchema, state: z.literal("success").prefault("success"), }); export const regionCheckerSchemaResponse = regionCheckerSchema.or( errorRequest.extend({ region: monitorRegionSchema, }), ); export type Timing = z.infer; export type Checker = z.infer; // FIXME: does not include TCP! export type RegionChecker = z.infer; export type RegionCheckerResponse = z.infer; export type Method = | "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "DELETE" | "PATCH" | "CONNECT" | "TRACE"; export type CachedRegionChecker = z.infer; export type ErrorRequest = z.infer; export async function checkRegion( url: string, region: Region, opts?: { method?: Method; headers?: { value: string; key: string }[]; body?: string; }, ): Promise { // // const regionInfo = regionDict[region]; let endpoint = ""; let regionHeader = {}; switch (regionInfo.provider) { case "fly": endpoint = `https://checker.openstatus.dev/ping/${region}`; regionHeader = { "fly-prefer-region": region }; break; case "koyeb": endpoint = `https://openstatus-checker.koyeb.app/ping/${region}`; regionHeader = { "X-KOYEB-REGION-OVERRIDE": region.replace("koyeb_", ""), }; break; case "railway": endpoint = `https://railway-proxy-production-9cb1.up.railway.app/ping/${region}`; regionHeader = { "railway-region": region.replace("railway_", "") }; break; default: break; } const res = await fetch(endpoint, { headers: { Authorization: `Basic ${process.env.CRON_SECRET}`, "Content-Type": "application/json", ...regionHeader, }, method: "POST", body: JSON.stringify({ url, method: opts?.method || "GET", headers: opts?.headers?.reduce((acc, { key, value }) => { if (!key) return acc; // key === "" is an invalid header return { // biome-ignore lint/performance/noAccumulatingSpread: ...acc, [key]: value, }; }, {}), body: opts?.body ? opts.body : undefined, }), next: { revalidate: 0 }, }); const json = await res.json(); const data = checkerSchema.or(errorRequest).safeParse(json); if (!data.success) { console.error(JSON.stringify(res)); console.error(JSON.stringify(json)); console.error( `something went wrong with request to ${url} error ${data.error.message}`, ); throw new Error(data.error.message); } return { region, ...data.data, }; } /** * Used for the /play/checker page only */ export async function checkAllRegions(url: string, opts?: { method: Method }) { // TODO: settleAll return await Promise.all( flyRegions.map(async (region) => { const check = await checkRegion(url, region, opts); if (check.state === "success") { // REMINDER: dropping the body to avoid storing it within Redis Cache (Err max request size exceeded) check.body = undefined; } return check; }), ); } export async function storeBaseCheckerData({ url, method, id, }: { url: string; method: Method; id: string; }) { const redis = Redis.fromEnv(); const timestamp = new Date().getTime(); const cache = { url, method, timestamp }; const parsed = cachedCheckerSchema .pick({ url: true, method: true, timestamp: true }) .safeParse(cache); if (!parsed.success) { throw new Error(parsed.error.message); } await redis.hset(`check:base:${id}`, parsed.data); const expire = 60 * 60 * 24 * 7; // 7days await redis.expire(`check:base:${id}`, expire); } export async function storeCheckerData({ check, id, }: { check: RegionChecker; id: string; }) { const redis = Redis.fromEnv(); const parsed = cachedCheckerSchema .pick({ checks: true }) .safeParse({ checks: [check] }); if (!parsed.success) { throw new Error(parsed.error.message); } const first = parsed.data.checks?.[0]; if (first) await redis.sadd(`check:data:${id}`, first); return id; } export async function getCheckerDataById(id: string) { const redis = Redis.fromEnv(); const pipe = redis.pipeline(); pipe.hgetall(`check:base:${id}`); pipe.smembers(`check:data:${id}`); const res = await pipe.exec< [{ url: string; method: Method; time: number }, RegionChecker] >(); if (!res) { return null; } const parsed = cachedCheckerSchema.safeParse({ ...res[0], checks: res[1] }); if (!parsed.success) { // throw new Error(parsed.error.message); return null; } return parsed.data; } /** * Simple function to validate crypto.randomUUID() format like "aec4e0ec3c4f4557b8ce46e55078fc95" * @param uuid * @returns */ export function is32CharHex(uuid: string) { const hexRegex = /^[0-9a-fA-F]{32}$/; return hexRegex.test(uuid); }