Openstatus www.openstatus.dev
1import { Redis } from "@upstash/redis"; 2import { z } from "zod"; 3 4import { 5 flyRegions, 6 monitorRegionSchema, 7} from "@openstatus/db/src/schema/constants"; 8import type { Region } from "@openstatus/db/src/schema/constants"; 9import { continentDict, getRegionInfo, regionDict } from "@openstatus/regions"; 10 11export function latencyFormatter(value: number) { 12 return `${new Intl.NumberFormat("us").format(value).toString()}ms`; 13} 14 15export function timestampFormatter(timestamp: number) { 16 return new Date(timestamp).toUTCString(); // GMT format 17} 18 19export function continentFormatter(region: Region) { 20 const continent = regionDict[region].continent; 21 return continentDict[continent].code; 22} 23 24export function regionFormatter( 25 region: string, 26 type: "short" | "long" = "short", 27) { 28 const { code, flag, location } = getRegionInfo(region); 29 if (type === "short") return `${code} ${flag}`; 30 return `${location} ${flag}`; 31} 32 33export function getTotalLatency(timing: Timing) { 34 const { dns, connection, tls, ttfb, transfer } = getTimingPhases(timing); 35 return dns + connection + tls + ttfb + transfer; 36} 37 38export function getTimingPhases(timing: Timing) { 39 const dns = timing.dnsDone - timing.dnsStart; 40 const connection = timing.connectDone - timing.connectStart; 41 const tls = timing.tlsHandshakeDone - timing.tlsHandshakeStart; 42 const ttfb = timing.firstByteDone - timing.firstByteStart; 43 const transfer = timing.transferDone - timing.transferStart; 44 45 return { 46 dns, 47 connection, 48 tls, 49 ttfb, 50 transfer, 51 }; 52} 53 54export function getTimingPhasesWidth(timing: Timing) { 55 const total = getTotalLatency(timing); 56 const phases = getTimingPhases(timing); 57 58 const dns = { preWidth: 0, width: (phases.dns / total) * 100 }; 59 60 const connection = { 61 preWidth: dns.preWidth + dns.width, 62 width: (phases.connection / total) * 100, 63 }; 64 65 const tls = { 66 preWidth: connection.preWidth + connection.width, 67 width: (phases.tls / total) * 100, 68 }; 69 70 const ttfb = { 71 preWidth: tls.preWidth + tls.width, 72 width: (phases.ttfb / total) * 100, 73 }; 74 75 const transfer = { 76 preWidth: ttfb.preWidth + ttfb.width, 77 width: (phases.transfer / total) * 100, 78 }; 79 80 return { 81 dns, 82 connection, 83 tls, 84 ttfb, 85 transfer, 86 }; 87} 88 89export const timingSchema = z.object({ 90 dnsStart: z.number(), 91 dnsDone: z.number(), 92 connectStart: z.number(), 93 connectDone: z.number(), 94 tlsHandshakeStart: z.number(), 95 tlsHandshakeDone: z.number(), 96 firstByteStart: z.number(), 97 firstByteDone: z.number(), 98 transferStart: z.number(), 99 transferDone: z.number(), 100}); 101 102export const checkerSchema = z.object({ 103 type: z.literal("http").prefault("http"), 104 state: z.literal("success").prefault("success"), 105 status: z.number(), 106 latency: z.number(), 107 headers: z.record(z.string(), z.string()), 108 timestamp: z.number(), 109 timing: timingSchema, 110 body: z.string().optional().nullable(), 111}); 112 113export const cachedCheckerSchema = z.object({ 114 url: z.string(), 115 timestamp: z.number(), 116 method: z.enum(["GET", "POST", "PUT", "DELETE"]).prefault("GET"), 117 checks: checkerSchema.extend({ region: monitorRegionSchema }).array(), 118}); 119 120const errorRequest = z.object({ 121 message: z.string(), 122 state: z.literal("error").prefault("error"), 123}); 124 125export const regionCheckerSchema = checkerSchema.extend({ 126 region: monitorRegionSchema, 127 state: z.literal("success").prefault("success"), 128}); 129 130export const regionCheckerSchemaResponse = regionCheckerSchema.or( 131 errorRequest.extend({ 132 region: monitorRegionSchema, 133 }), 134); 135export type Timing = z.infer<typeof timingSchema>; 136export type Checker = z.infer<typeof checkerSchema>; 137// FIXME: does not include TCP! 138export type RegionChecker = z.infer<typeof regionCheckerSchema>; 139export type RegionCheckerResponse = z.infer<typeof regionCheckerSchemaResponse>; 140export type Method = 141 | "GET" 142 | "HEAD" 143 | "OPTIONS" 144 | "POST" 145 | "PUT" 146 | "DELETE" 147 | "PATCH" 148 | "CONNECT" 149 | "TRACE"; 150export type CachedRegionChecker = z.infer<typeof cachedCheckerSchema>; 151 152export type ErrorRequest = z.infer<typeof errorRequest>; 153export async function checkRegion( 154 url: string, 155 region: Region, 156 opts?: { 157 method?: Method; 158 headers?: { value: string; key: string }[]; 159 body?: string; 160 }, 161): Promise<RegionCheckerResponse> { 162 // 163 // 164 const regionInfo = regionDict[region]; 165 166 let endpoint = ""; 167 let regionHeader = {}; 168 switch (regionInfo.provider) { 169 case "fly": 170 endpoint = `https://checker.openstatus.dev/ping/${region}`; 171 regionHeader = { "fly-prefer-region": region }; 172 break; 173 case "koyeb": 174 endpoint = `https://openstatus-checker.koyeb.app/ping/${region}`; 175 regionHeader = { 176 "X-KOYEB-REGION-OVERRIDE": region.replace("koyeb_", ""), 177 }; 178 break; 179 case "railway": 180 endpoint = `https://railway-proxy-production-9cb1.up.railway.app/ping/${region}`; 181 regionHeader = { "railway-region": region.replace("railway_", "") }; 182 break; 183 default: 184 break; 185 } 186 187 const res = await fetch(endpoint, { 188 headers: { 189 Authorization: `Basic ${process.env.CRON_SECRET}`, 190 "Content-Type": "application/json", 191 ...regionHeader, 192 }, 193 method: "POST", 194 body: JSON.stringify({ 195 url, 196 method: opts?.method || "GET", 197 headers: opts?.headers?.reduce((acc, { key, value }) => { 198 if (!key) return acc; // key === "" is an invalid header 199 200 return { 201 // biome-ignore lint/performance/noAccumulatingSpread: <explanation> 202 ...acc, 203 [key]: value, 204 }; 205 }, {}), 206 body: opts?.body ? opts.body : undefined, 207 }), 208 next: { revalidate: 0 }, 209 }); 210 211 const json = await res.json(); 212 213 const data = checkerSchema.or(errorRequest).safeParse(json); 214 215 if (!data.success) { 216 console.error(JSON.stringify(res)); 217 console.error(JSON.stringify(json)); 218 console.error( 219 `something went wrong with request to ${url} error ${data.error.message}`, 220 ); 221 throw new Error(data.error.message); 222 } 223 224 return { 225 region, 226 ...data.data, 227 }; 228} 229 230/** 231 * Used for the /play/checker page only 232 */ 233export async function checkAllRegions(url: string, opts?: { method: Method }) { 234 // TODO: settleAll 235 return await Promise.all( 236 flyRegions.map(async (region) => { 237 const check = await checkRegion(url, region, opts); 238 if (check.state === "success") { 239 // REMINDER: dropping the body to avoid storing it within Redis Cache (Err max request size exceeded) 240 check.body = undefined; 241 } 242 return check; 243 }), 244 ); 245} 246 247export async function storeBaseCheckerData({ 248 url, 249 method, 250 id, 251}: { 252 url: string; 253 method: Method; 254 id: string; 255}) { 256 const redis = Redis.fromEnv(); 257 const timestamp = new Date().getTime(); 258 const cache = { url, method, timestamp }; 259 260 const parsed = cachedCheckerSchema 261 .pick({ url: true, method: true, timestamp: true }) 262 .safeParse(cache); 263 264 if (!parsed.success) { 265 throw new Error(parsed.error.message); 266 } 267 268 await redis.hset(`check:base:${id}`, parsed.data); 269 const expire = 60 * 60 * 24 * 7; // 7days 270 await redis.expire(`check:base:${id}`, expire); 271} 272 273export async function storeCheckerData({ 274 check, 275 id, 276}: { 277 check: RegionChecker; 278 id: string; 279}) { 280 const redis = Redis.fromEnv(); 281 282 const parsed = cachedCheckerSchema 283 .pick({ checks: true }) 284 .safeParse({ checks: [check] }); 285 286 if (!parsed.success) { 287 throw new Error(parsed.error.message); 288 } 289 290 const first = parsed.data.checks?.[0]; 291 292 if (first) await redis.sadd(`check:data:${id}`, first); 293 294 return id; 295} 296 297export async function getCheckerDataById(id: string) { 298 const redis = Redis.fromEnv(); 299 const pipe = redis.pipeline(); 300 pipe.hgetall(`check:base:${id}`); 301 pipe.smembers(`check:data:${id}`); 302 303 const res = 304 await pipe.exec< 305 [{ url: string; method: Method; time: number }, RegionChecker] 306 >(); 307 308 if (!res) { 309 return null; 310 } 311 312 const parsed = cachedCheckerSchema.safeParse({ ...res[0], checks: res[1] }); 313 314 if (!parsed.success) { 315 // throw new Error(parsed.error.message); 316 return null; 317 } 318 319 return parsed.data; 320} 321 322/** 323 * Simple function to validate crypto.randomUUID() format like "aec4e0ec3c4f4557b8ce46e55078fc95" 324 * @param uuid 325 * @returns 326 */ 327export function is32CharHex(uuid: string) { 328 const hexRegex = /^[0-9a-fA-F]{32}$/; 329 return hexRegex.test(uuid); 330}