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}