Openstatus www.openstatus.dev

Move to fly (#365)

* 🛬

* ✈️

* ✈️ fly

* ✈️ fly

* ✈️ fly

* ✈️ fly

* 🚀

* 🟢

* 🚧

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🧪 fix tests

* 🤯

* 🤯

* 🔓

* 😭

* 😭

authored by

Thibault Le Ouay and committed by
GitHub
f90674af 8fae023a

+677 -103
+1 -2
.github/workflows/test.yml
··· 33 33 - name: ⎔ Setup node 34 34 uses: actions/setup-node@v3 35 35 with: 36 - node-version: 18 36 + node-version: 20 37 37 cache: "pnpm" 38 38 39 39 - name: 🔥 Install bun ··· 54 54 55 55 - name: 🧪 Tests 56 56 run: pnpm test 57 - working-directory: ./packages/api
+11
apps/server/.env.test
··· 1 + DATABASE_URL=http://127.0.0.1:8080 2 + DATABASE_AUTH_TOKEN= 3 + NODE_ENV=test 4 + UNKEY_TOKEN=test 5 + TINY_BIRD_API_KEY=test 6 + UPSTASH_REDIS_REST_URL=test 7 + UPSTASH_REDIS_REST_TOKEN=test 8 + QSTASH_CURRENT_SIGNING_KEY=test 9 + QSTASH_NEXT_SIGNING_KEY=test 10 + FLY_REGION=ams 11 + RESEND_API_KEY=test
+1
apps/server/fly.toml
··· 6 6 app = "openstatus-api" 7 7 primary_region = "ams" 8 8 9 + 9 10 [build] 10 11 dockerfile = "./Dockerfile" 11 12
+9 -3
apps/server/package.json
··· 6 6 "main": "src/index.ts", 7 7 "scripts": { 8 8 "dev": "bun run --hot src/index.ts", 9 - "start": "NODE_ENV=production bun run src/index.ts" 9 + "start": "NODE_ENV=production bun run src/index.ts", 10 + "test": "vitest run" 10 11 }, 11 12 "dependencies": { 12 13 "@hono/zod-openapi": "0.7.1", 13 14 "@openstatus/db": "workspace:*", 15 + "@openstatus/notification-emails": "workspace:*", 14 16 "@openstatus/plans": "workspace:*", 15 17 "@openstatus/tinybird": "workspace:*", 16 18 "@openstatus/upstash": "workspace:*", 19 + "@t3-oss/env-core": "0.7.0", 17 20 "@unkey/api": "0.10.0", 18 - "hono": "3.7.3", 21 + "hono": "3.7.6", 22 + "nanoid": "5.0.1", 19 23 "zod": "3.22.2" 20 24 }, 21 25 "devDependencies": { 22 - "@openstatus/tsconfig": "workspace:*" 26 + "@openstatus/tsconfig": "workspace:*", 27 + "vitest": "0.34.6", 28 + "dotenv": "16.3.1" 23 29 } 24 30 }
+12
apps/server/src/checker/alerting.test.ts
··· 1 + import { expect, it, vi } from "vitest"; 2 + 3 + import { triggerAlerting } from "./alerting"; 4 + import * as utils from "./utils"; 5 + 6 + it("should send email notification", async () => { 7 + vi.mock("utils"); 8 + const mockedFn = vi.fn(); 9 + utils.providerToFunction["email"] = mockedFn; 10 + await triggerAlerting({ monitorId: "1" }); 11 + expect(mockedFn).toHaveBeenCalledTimes(1); 12 + });
+55
apps/server/src/checker/alerting.ts
··· 1 + import type { z } from "zod"; 2 + 3 + import { db, eq, schema } from "@openstatus/db"; 4 + import { selectNotificationSchema } from "@openstatus/db/src/schema"; 5 + 6 + import { monitor } from "./checker"; 7 + import type { Payload } from "./schema"; 8 + import { providerToFunction } from "./utils"; 9 + 10 + export async function catchTooManyRetry(payload: Payload) { 11 + await monitor({ monitorInfo: payload, latency: -1, statusCode: 500 }); 12 + if (payload?.status !== "error") { 13 + await triggerAlerting({ monitorId: payload.monitorId }); 14 + await updateMonitorStatus({ 15 + monitorId: payload.monitorId, 16 + status: "error", 17 + }); 18 + } 19 + } 20 + 21 + export const triggerAlerting = async ({ monitorId }: { monitorId: string }) => { 22 + const notifications = await db 23 + .select() 24 + .from(schema.notificationsToMonitors) 25 + .innerJoin( 26 + schema.notification, 27 + eq(schema.notification.id, schema.notificationsToMonitors.notificationId), 28 + ) 29 + .innerJoin( 30 + schema.monitor, 31 + eq(schema.monitor.id, schema.notificationsToMonitors.monitorId), 32 + ) 33 + .where(eq(schema.monitor.id, Number(monitorId))) 34 + .all(); 35 + for (const notif of notifications) { 36 + await providerToFunction[notif.notification.provider]({ 37 + monitor: notif.monitor, 38 + notification: selectNotificationSchema.parse(notif.notification), 39 + }); 40 + } 41 + }; 42 + 43 + export const updateMonitorStatus = async ({ 44 + monitorId, 45 + status, 46 + }: { 47 + monitorId: string; 48 + status: z.infer<typeof schema.statusSchema>; 49 + }) => { 50 + await db 51 + .update(schema.monitor) 52 + .set({ status }) 53 + .where(eq(schema.monitor.id, Number(monitorId))) 54 + .run(); 55 + };
+70
apps/server/src/checker/checker.test.ts
··· 1 + import { expect, it, vi } from "vitest"; 2 + 3 + // REMINDER: keep it here for the mock 4 + import type { Tinybird } from "@openstatus/tinybird"; 5 + import { 6 + publishPingResponse, 7 + tbIngestPingResponse, 8 + } from "@openstatus/tinybird"; 9 + 10 + import * as alerts from "./alerting"; 11 + import { checker } from "./checker"; 12 + 13 + vi.mock("@openstatus/tinybird", async () => { 14 + const actual = await vi.importActual("@openstatus/tinybird"); 15 + return { 16 + // @ts-ignore 17 + ...actual, 18 + publishPingResponse: vi.fn(), 19 + }; 20 + }); 21 + 22 + it("should call updateMonitorStatus when we can fetch", async () => { 23 + const spyOn = vi.spyOn(alerts, "updateMonitorStatus").mockReturnThis(); 24 + await checker({ 25 + workspaceId: "1", 26 + monitorId: "1", 27 + url: "https://www.google.com", 28 + cronTimestamp: 1, 29 + status: "error", 30 + pageIds: [], 31 + method: "GET", 32 + }); 33 + expect(spyOn).toHaveBeenCalledTimes(1); 34 + }); 35 + 36 + it("should call updateMonitorStatus when status error", async () => { 37 + const spyOn = vi.spyOn(alerts, "updateMonitorStatus").mockReturnThis(); 38 + try { 39 + await checker({ 40 + workspaceId: "1", 41 + monitorId: "1", 42 + url: "https://xxxxxxx.fake", 43 + cronTimestamp: 1, 44 + status: "active", 45 + pageIds: [], 46 + method: "GET", 47 + }); 48 + } catch (e) { 49 + expect(e).toBeInstanceOf(Error); 50 + } 51 + expect(spyOn).toHaveBeenCalledTimes(0); 52 + }); 53 + 54 + it("What should we do when redirect ", async () => { 55 + const spyOn = vi.spyOn(alerts, "updateMonitorStatus").mockReturnThis(); 56 + try { 57 + await checker({ 58 + workspaceId: "1", 59 + monitorId: "1", 60 + url: "https://www.openstatus.dev/toto", 61 + cronTimestamp: 1, 62 + status: "active", 63 + pageIds: [], 64 + method: "GET", 65 + }); 66 + } catch (e) { 67 + expect(e).toBeInstanceOf(Error); 68 + } 69 + expect(spyOn).toHaveBeenCalledTimes(0); 70 + });
+81
apps/server/src/checker/checker.ts
··· 1 + import { nanoid } from "nanoid"; 2 + 3 + import { publishPingResponse } from "@openstatus/tinybird"; 4 + 5 + import { env } from "../env"; 6 + import { updateMonitorStatus } from "./alerting"; 7 + import type { Payload } from "./schema"; 8 + 9 + const region = env.FLY_REGION; 10 + 11 + export const monitor = async ({ 12 + monitorInfo, 13 + latency, 14 + statusCode, 15 + }: { 16 + monitorInfo: Payload; 17 + latency: number; 18 + statusCode: number; 19 + }) => { 20 + const { monitorId, cronTimestamp, url, workspaceId } = monitorInfo; 21 + 22 + await publishPingResponse({ 23 + id: nanoid(), // TBD: we don't need it 24 + timestamp: Date.now(), 25 + statusCode, 26 + latency, 27 + region, 28 + url, 29 + monitorId, 30 + cronTimestamp, 31 + workspaceId, 32 + }); 33 + }; 34 + 35 + export const checker = async (data: Payload) => { 36 + const startTime = Date.now(); 37 + const res = await ping(data); 38 + const endTime = Date.now(); 39 + const latency = endTime - startTime; 40 + await monitor({ monitorInfo: data, latency, statusCode: res.status }); 41 + if (res.ok && !res.redirected) { 42 + if (data?.status === "error") { 43 + await updateMonitorStatus({ 44 + monitorId: data.monitorId, 45 + status: "active", 46 + }); 47 + } 48 + 49 + if (!res.ok || (res.ok && !res.redirected)) { 50 + if (data?.status === "active") { 51 + await updateMonitorStatus({ 52 + monitorId: data.monitorId, 53 + status: "error", 54 + }); 55 + } 56 + } 57 + } 58 + }; 59 + 60 + export const ping = async ( 61 + data: Pick<Payload, "headers" | "body" | "method" | "url">, 62 + ) => { 63 + const headers = 64 + data?.headers?.reduce((o, v) => { 65 + if (v.key.trim() === "") return o; // removes empty keys from the header 66 + return { ...o, [v.key]: v.value }; 67 + }, {}) || {}; 68 + 69 + const res = await fetch(data?.url, { 70 + method: data?.method, 71 + cache: "no-store", 72 + headers: { 73 + "OpenStatus-Ping": "true", 74 + ...headers, 75 + }, 76 + // Avoid having "TypeError: Request with a GET or HEAD method cannot have a body." error 77 + ...(data.method !== "GET" && { body: data?.body }), 78 + }); 79 + 80 + return res; 81 + };
+23
apps/server/src/checker/index.ts
··· 1 + import { Hono } from "hono"; 2 + 3 + import { checker } from "./checker"; 4 + import { middleware } from "./middleware"; 5 + import type { Payload } from "./schema"; 6 + 7 + export type Variables = { 8 + payload: Payload; 9 + }; 10 + 11 + export const checkerRoute = new Hono<{ Variables: Variables }>(); 12 + 13 + checkerRoute.use("/*", middleware); 14 + 15 + // TODO: only use checkerRoute.post("/checker", checker); 16 + checkerRoute.post("/checker", async (c) => { 17 + const payload = c.get("payload"); 18 + try { 19 + checker(payload); 20 + } catch (e) { 21 + console.error(e); 22 + } 23 + });
+46
apps/server/src/checker/middleware.ts
··· 1 + import { Receiver } from "@upstash/qstash"; 2 + import type { Context, Next } from "hono"; 3 + 4 + import { env } from "../env"; 5 + import { catchTooManyRetry } from "./alerting"; 6 + import type { Variables } from "./index"; 7 + import { payloadSchema } from "./schema"; 8 + 9 + const r = new Receiver({ 10 + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, 11 + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, 12 + }); 13 + 14 + export async function middleware( 15 + c: Context<{ Variables: Variables }, "/*", {}>, 16 + next: Next, 17 + ) { 18 + const json = await c.req.json(); 19 + 20 + const isValid = r.verify({ 21 + signature: c.req.header("Upstash-Signature") || "", 22 + body: JSON.stringify(json), 23 + }); 24 + 25 + if (!isValid) { 26 + return c.text("Unauthorized", 401); 27 + } 28 + 29 + const result = payloadSchema.safeParse(json); 30 + 31 + if (!result.success) { 32 + console.error(result.error); 33 + return c.text("Unprocessable Entity", 422); 34 + } 35 + 36 + /** 37 + * Alert user after third retry, on forth try 38 + */ 39 + if (c.req.header("Upstash-Retried") === "3") { 40 + catchTooManyRetry(result.data); 41 + return c.text("", 200); // needs to be 200, otherwise qstash will retry 42 + } 43 + 44 + c.set("payload", result.data); 45 + await next(); 46 + }
+17
apps/server/src/checker/schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { METHODS, status } from "@openstatus/db/src/schema"; 4 + 5 + export const payloadSchema = z.object({ 6 + workspaceId: z.string(), 7 + monitorId: z.string(), 8 + method: z.enum(METHODS), 9 + body: z.string().optional(), 10 + headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 11 + url: z.string(), 12 + cronTimestamp: z.number(), 13 + pageIds: z.array(z.string()), 14 + status: z.enum(status), 15 + }); 16 + 17 + export type Payload = z.infer<typeof payloadSchema>;
+40
apps/server/src/checker/utils.ts
··· 1 + import type { z } from "zod"; 2 + 3 + import type { 4 + basicMonitorSchema, 5 + providerName, 6 + selectNotificationSchema, 7 + } from "@openstatus/db/src/schema"; 8 + import { send as sendEmail } from "@openstatus/notification-emails"; 9 + 10 + type ProviderName = (typeof providerName)[number]; 11 + 12 + type sendNotificationType = ({ 13 + monitor, 14 + notification, 15 + }: { 16 + monitor: z.infer<typeof basicMonitorSchema>; 17 + notification: z.infer<typeof selectNotificationSchema>; 18 + }) => Promise<void>; 19 + 20 + export const providerToFunction = { 21 + email: sendEmail, 22 + slack: async ({ 23 + monitor, 24 + notification, 25 + }: { 26 + monitor: any; 27 + notification: any; 28 + }) => { 29 + /* TODO: implement */ 30 + }, 31 + discord: async ({ 32 + monitor, 33 + notification, 34 + }: { 35 + monitor: any; 36 + notification: any; 37 + }) => { 38 + /* TODO: implement */ 39 + }, 40 + } satisfies Record<ProviderName, sendNotificationType>;
+36
apps/server/src/env.ts
··· 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 + 4 + export const env = createEnv({ 5 + server: { 6 + UNKEY_API_ID: z.string().min(1), 7 + UNKEY_TOKEN: z.string().min(1), 8 + TINY_BIRD_API_KEY: z.string().min(1), 9 + UPSTASH_REDIS_REST_URL: z.string().min(1), 10 + UPSTASH_REDIS_REST_TOKEN: z.string().min(1), 11 + QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), 12 + QSTASH_NEXT_SIGNING_KEY: z.string().min(1), 13 + FLY_REGION: z.string(), 14 + }, 15 + 16 + /** 17 + * What object holds the environment variables at runtime. This is usually 18 + * `process.env` or `import.meta.env`. 19 + */ 20 + runtimeEnv: process.env, 21 + 22 + /** 23 + * By default, this library will feed the environment variables directly to 24 + * the Zod validator. 25 + * 26 + * This means that if you have an empty string for a value that is supposed 27 + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag 28 + * it as a type mismatch violation. Additionally, if you have an empty string 29 + * for a value that is supposed to be a string with a default value (e.g. 30 + * `DOMAIN=` in an ".env" file), the default value will never be applied. 31 + * 32 + * In order to solve these issues, we recommend that all new projects 33 + * explicitly specify this option as true. 34 + */ 35 + skipValidation: true, 36 + });
+2 -1
apps/server/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 3 + import { env } from "./env"; 3 4 import { publicRoute } from "./public"; 4 5 import { api } from "./v1"; 5 6 import { VercelIngest } from "./vercel"; ··· 19 20 /** 20 21 * Ping Pong 21 22 */ 22 - app.get("/ping", (c) => c.text("pong")); 23 + app.get("/ping", (c) => c.json({ ping: "pong", region: env.FLY_REGION })); 23 24 24 25 /** 25 26 * API Routes v1
+3 -1
apps/server/src/public/status.ts
··· 5 5 import { getMonitorList, Tinybird } from "@openstatus/tinybird"; 6 6 import { Redis } from "@openstatus/upstash"; 7 7 8 + import { env } from "../env"; 9 + 8 10 // TODO: include ratelimiting 9 11 10 - const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY || "" }); 12 + const tb = new Tinybird({ token: env.TINY_BIRD_API_KEY }); 11 13 const redis = Redis.fromEnv(); 12 14 13 15 enum Status {
+61
apps/server/src/v1/monitor.test.ts
··· 1 + import { expect, it } from "vitest"; 2 + 3 + import { api } from "."; 4 + 5 + it("GET one monitor", async () => { 6 + const res = await api.request("/monitor/1", { 7 + headers: { 8 + "x-openstatus-key": "1", 9 + }, 10 + }); 11 + expect(res.status).toBe(200); 12 + 13 + expect(await res.json()).toEqual({ 14 + id: 1, 15 + periodicity: "1m", 16 + url: "https://www.openstatus.dev", 17 + regions: "ams", 18 + name: "OpenStatus", 19 + description: "OpenStatus website", 20 + method: "POST", 21 + body: '{"hello":"world"}', 22 + headers: [{ key: "key", value: "value" }], 23 + active: true, 24 + }); 25 + }); 26 + 27 + it("GET all monitor", async () => { 28 + const res = await api.request("/monitor", { 29 + headers: { 30 + "x-openstatus-key": "1", 31 + }, 32 + }); 33 + expect(res.status).toBe(200); 34 + 35 + expect(await res.json()).toEqual([ 36 + { 37 + id: 1, 38 + periodicity: "1m", 39 + url: "https://www.openstatus.dev", 40 + regions: "ams", 41 + name: "OpenStatus", 42 + description: "OpenStatus website", 43 + method: "POST", 44 + body: '{"hello":"world"}', 45 + headers: [{ key: "key", value: "value" }], 46 + active: true, 47 + }, 48 + { 49 + active: false, 50 + body: "", 51 + description: "", 52 + headers: [], 53 + id: 2, 54 + method: "GET", 55 + name: "", 56 + periodicity: "10m", 57 + regions: "gru", 58 + url: "https://www.google.com", 59 + }, 60 + ]); 61 + });
+34 -12
apps/server/src/v1/monitor.ts
··· 2 2 3 3 import { db, eq, sql } from "@openstatus/db"; 4 4 import { 5 - availableRegions, 5 + flyRegions, 6 6 METHODS, 7 7 monitor, 8 8 periodicity, ··· 27 27 28 28 export const periodicityEnum = z.enum(periodicity); 29 29 export const regionEnum = z 30 - .enum(availableRegions) 30 + .enum(flyRegions) 31 31 .or(z.literal("")) 32 - .transform((val) => (val === "" ? "auto" : val)); 32 + .transform((val) => (val === "" ? "" : val)); 33 33 34 34 const MonitorSchema = z 35 35 .object({ ··· 46 46 description: "The url to monitor", 47 47 }), 48 48 regions: regionEnum.openapi({ 49 - example: "arn1", 49 + example: "ams", 50 50 description: "The regions to use", 51 51 }), 52 52 name: z ··· 108 108 example: "https://www.documenso.co", 109 109 description: "The url to monitor", 110 110 }), 111 - regions: regionEnum 112 - .openapi({ 113 - example: "arn1", 114 - description: "The regions to use", 115 - }) 116 - .default("auto"), 111 + regions: regionEnum.openapi({ 112 + example: "ams", 113 + description: "The regions to use", 114 + }), 117 115 name: z.string().openapi({ 118 116 example: "Documenso", 119 117 description: "The name of the monitor", ··· 166 164 }, 167 165 description: "Get the monitor", 168 166 }, 169 - 400: { 167 + 404: { 168 + content: { 169 + "application/json": { 170 + schema: ErrorSchema, 171 + }, 172 + }, 173 + description: "Not found", 174 + }, 175 + 401: { 170 176 content: { 171 177 "application/json": { 172 178 schema: ErrorSchema, ··· 208 214 }, 209 215 description: "Get the monitor", 210 216 }, 211 - 400: { 217 + 401: { 218 + content: { 219 + "application/json": { 220 + schema: ErrorSchema, 221 + }, 222 + }, 223 + description: "Returns an error", 224 + }, 225 + 404: { 212 226 content: { 213 227 "application/json": { 214 228 schema: ErrorSchema, ··· 265 279 description: "Create a monitor", 266 280 }, 267 281 400: { 282 + content: { 283 + "application/json": { 284 + schema: ErrorSchema, 285 + }, 286 + }, 287 + description: "Returns an error", 288 + }, 289 + 403: { 268 290 content: { 269 291 "application/json": { 270 292 schema: ErrorSchema,
+2 -1
apps/server/src/vercel.ts
··· 2 2 3 3 import { Tinybird } from "@openstatus/tinybird"; 4 4 5 + import { env } from "./env"; 5 6 import { logDrainSchema, logDrainSchemaArray } from "./schema/vercel"; 6 7 7 - const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY || "" }); // should we use t3-env? 8 + const tb = new Tinybird({ token: env.TINY_BIRD_API_KEY }); // should we use t3-env? 8 9 9 10 export function publishVercelLogDrain() { 10 11 return tb.buildIngestEndpoint({
+11
apps/server/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + // setupFiles: ["dotenv/config"], //this line, 6 + globals: true, 7 + }, 8 + define: { 9 + "process.env.FLY_REGION": `"ams"`, 10 + }, 11 + });
+1 -1
apps/web/package.json
··· 27 27 "@sentry/integrations": "7.73.0", 28 28 "@sentry/nextjs": "7.73.0", 29 29 "@stripe/stripe-js": "2.1.6", 30 - "@t3-oss/env-nextjs": "0.6.1", 30 + "@t3-oss/env-nextjs": "0.7.0", 31 31 "@tailwindcss/typography": "0.5.10", 32 32 "@tanstack/react-table": "8.10.3", 33 33 "@tremor/react": "3.8.2",
+16 -9
apps/web/src/app/api/checker/cron/_cron.ts
··· 5 5 6 6 import { createTRPCContext } from "@openstatus/api"; 7 7 import { edgeRouter } from "@openstatus/api/src/edge"; 8 - import { selectMonitorSchema } from "@openstatus/db/src/schema"; 9 - import { availableRegions } from "@openstatus/tinybird"; 8 + import { flyRegions, selectMonitorSchema } from "@openstatus/db/src/schema"; 10 9 11 10 import { env } from "@/env"; 12 11 import type { payloadSchema } from "../schema"; ··· 64 63 65 64 // TODO: fetch + try - catch + retry once 66 65 const result = c.publishJSON({ 67 - url: `${DEFAULT_URL}/api/checker/regions/auto`, 66 + url: `https://api.openstatus.dev/checker`, 68 67 body: payload, 69 68 delay: Math.random() * 90, 69 + headers: { 70 + "fly-prefer-region": "ams", 71 + }, 70 72 }); 71 73 allResult.push(result); 72 74 } else { 73 - const allMonitorsRegions = row.regions; 74 - for (const region of allMonitorsRegions) { 75 + for (const region of flyRegions) { 75 76 const payload: z.infer<typeof payloadSchema> = { 76 77 workspaceId: String(row.workspaceId), 77 78 monitorId: String(row.id), ··· 85 86 }; 86 87 87 88 const result = c.publishJSON({ 88 - url: `${DEFAULT_URL}/api/checker/regions/${region}`, 89 + url: `https://api.openstatus.dev/checker`, 89 90 body: payload, 91 + headers: { 92 + "fly-prefer-region": region, 93 + }, 90 94 }); 91 95 allResult.push(result); 92 96 } ··· 95 99 // our first legacy monitor 96 100 if (periodicity === "10m") { 97 101 // Right now we are just checking the ping endpoint 98 - for (const region of availableRegions) { 102 + for (const region of flyRegions) { 99 103 const payload: z.infer<typeof payloadSchema> = { 100 104 workspaceId: "openstatus", 101 105 monitorId: "openstatusPing", 102 - url: `${DEFAULT_URL}/api/ping`, 106 + url: `https://api.openstatus.dev/ping`, 103 107 cronTimestamp: timestamp, 104 108 method: "GET", 105 109 pageIds: ["openstatus"], ··· 108 112 109 113 // TODO: fetch + try - catch + retry once 110 114 const result = c.publishJSON({ 111 - url: `${DEFAULT_URL}/api/checker/regions/${region}`, 115 + url: `https://api.openstatus.dev/checker`, 112 116 body: payload, 117 + headers: { 118 + "fly-prefer-region": region, 119 + }, 113 120 delay: Math.random() * 90, 114 121 }); 115 122 allResult.push(result);
+1 -1
apps/web/src/app/api/checker/regions/_checker.ts
··· 30 30 ) => { 31 31 const { monitorId, cronTimestamp, url, workspaceId } = payload; 32 32 33 - await publishPingResponse(tb)({ 33 + await publishPingResponse({ 34 34 id: nanoid(), // TBD: we don't need it 35 35 timestamp: Date.now(), 36 36 statusCode,
-3
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/page.tsx
··· 3 3 import { endOfDay, startOfDay } from "date-fns"; 4 4 import * as z from "zod"; 5 5 6 - import { availableRegions } from "@openstatus/tinybird"; 7 - 8 6 import { Header } from "@/components/dashboard/header"; 9 7 import { columns } from "@/components/data-table/columns"; 10 8 import { DataTable } from "@/components/data-table/data-table"; ··· 21 19 */ 22 20 const searchParamsSchema = z.object({ 23 21 statusCode: z.coerce.number().optional(), 24 - region: z.enum(availableRegions).optional(), 25 22 cronTimestamp: z.coerce.number().optional(), 26 23 fromDate: z.coerce 27 24 .number()
+6 -6
apps/web/src/components/forms/montitor-form.tsx
··· 57 57 58 58 import { LoadingAnimation } from "@/components/loading-animation"; 59 59 import { FailedPingAlertConfirmation } from "@/components/modals/failed-ping-alert-confirmation"; 60 - import { regionsDict } from "@/data/regions-dictionary"; 60 + import { flyRegionsDict } from "@/data/regions-dictionary"; 61 61 import { useToastAction } from "@/hooks/use-toast-action"; 62 62 import useUpdateSearchParams from "@/hooks/use-update-search-params"; 63 63 import { cn } from "@/lib/utils"; ··· 481 481 {/* This is a hotfix */} 482 482 {field.value?.length === 1 && 483 483 field.value[0].length > 0 484 - ? regionsDict[ 484 + ? flyRegionsDict[ 485 485 field 486 - .value[0] as keyof typeof regionsDict 486 + .value[0] as keyof typeof flyRegionsDict 487 487 ].location 488 488 : "Select region"} 489 489 <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> ··· 495 495 <CommandInput placeholder="Select a region..." /> 496 496 <CommandEmpty>No regions found.</CommandEmpty> 497 497 <CommandGroup className="max-h-[150px] overflow-y-scroll"> 498 - {Object.keys(regionsDict).map((region) => { 498 + {Object.keys(flyRegionsDict).map((region) => { 499 499 const { code, location } = 500 - regionsDict[ 501 - region as keyof typeof regionsDict 500 + flyRegionsDict[ 501 + region as keyof typeof flyRegionsDict 502 502 ]; 503 503 const isSelected = 504 504 field.value?.includes(code);
+37 -1
apps/web/src/data/regions-dictionary.ts
··· 2 2 * AWS data center informations from 18 regions, supported by vercel. 3 3 * https://vercel.com/docs/concepts/edge-network/regions#region-list 4 4 */ 5 - export const regionsDict = { 5 + export const vercelRegionsDict = { 6 6 /** 7 7 * A random location will be chosen 8 8 */ ··· 102 102 location: "Sydney, Australia", 103 103 }, 104 104 } as const; 105 + 106 + export const flyRegionsDict = { 107 + ams: { 108 + code: "ams", 109 + name: "ap-southeast-2", 110 + location: "Amsterdam, Netherlands", 111 + }, 112 + iad: { 113 + code: "iad", 114 + name: "us-east-1", 115 + location: "Ashburn, Virginia, USA", 116 + }, 117 + jnb: { 118 + code: "jnb", 119 + name: "", 120 + location: "Johannesburg, South Africa", 121 + }, 122 + hkg: { 123 + code: "hkg", 124 + name: "", 125 + location: "Hong Kong, Hong Kong", 126 + }, 127 + gru: { 128 + code: "gru", 129 + name: "", 130 + location: "Sao Paulo, Brazil", 131 + }, 132 + 133 + syd: { 134 + code: "syd", 135 + name: "ap-southeast-2", 136 + location: "Sydney, Australia", 137 + }, 138 + } as const; 139 + 140 + export const regionsDict = { ...vercelRegionsDict, ...flyRegionsDict } as const;
bun.lockb

This is a binary file and will not be displayed.

+2 -1
package.json
··· 7 7 "lint:strict": "eslint --max-warnings=0 apps/**/*.{ts,tsx}", 8 8 "format": "prettier --write \"**/*.{ts,tsx,md}\"", 9 9 "format:check": "prettier -c \"**/*.{ts,tsx,md}\"", 10 - "tsc": "tsc" 10 + "tsc": "tsc", 11 + "test": "turbo run test" 11 12 }, 12 13 "devDependencies": { 13 14 "@ianvs/prettier-plugin-sort-imports": "4.1.0",
+1 -1
packages/analytics/package.json
··· 5 5 "main": "src/index.ts", 6 6 "dependencies": { 7 7 "@jitsu/js": "1.3.0", 8 - "@t3-oss/env-core": "0.6.1", 8 + "@t3-oss/env-core": "0.7.0", 9 9 "zod": "3.22.2" 10 10 }, 11 11 "devDependencies": {
+2 -2
packages/api/package.json
··· 4 4 "description": "", 5 5 "main": "index.ts", 6 6 "scripts": { 7 - "test": "vitest" 7 + "test": "vitest run" 8 8 }, 9 9 "dependencies": { 10 10 "@clerk/nextjs": "4.25.1", ··· 12 12 "@openstatus/db": "workspace:*", 13 13 "@openstatus/emails": "workspace:*", 14 14 "@openstatus/plans": "workspace:*", 15 - "@t3-oss/env-core": "0.6.1", 15 + "@t3-oss/env-core": "0.7.0", 16 16 "@trpc/client": "10.38.5", 17 17 "@trpc/server": "10.38.5", 18 18 "random-word-slugs": "0.1.7",
+1 -1
packages/db/package.json
··· 12 12 }, 13 13 "dependencies": { 14 14 "@libsql/client": "0.3.4", 15 - "@t3-oss/env-core": "0.6.1", 15 + "@t3-oss/env-core": "0.7.0", 16 16 "dotenv": "16.3.1", 17 17 "drizzle-orm": "0.28.6", 18 18 "drizzle-zod": "0.5.1",
+4 -3
packages/db/src/schema/monitor.ts
··· 13 13 import { page } from "./page"; 14 14 import { workspace } from "./workspace"; 15 15 16 - export const availableRegions = [ 17 - "auto", // randomly choose region 16 + export const vercelRegions = [ 18 17 "arn1", 19 18 "bom1", 20 19 "cdg1", ··· 35 34 "syd1", 36 35 ] as const; 37 36 37 + export const flyRegions = ["ams", "iad", "hkg", "jnb", "syd", "gru"] as const; 38 + 38 39 export const periodicity = ["1m", "5m", "10m", "30m", "1h", "other"] as const; 39 40 export const METHODS = ["GET", "POST"] as const; 40 41 export const status = ["active", "error"] as const; 41 42 export const statusSchema = z.enum(status); 42 - export const RegionEnum = z.enum(availableRegions); 43 + export const RegionEnum = z.enum([...flyRegions, ...vercelRegions, "auto"]); 43 44 44 45 export const monitor = sqliteTable("monitor", { 45 46 id: integer("id").primaryKey(),
+18 -2
packages/db/src/seed.mts
··· 7 7 import { 8 8 monitor, 9 9 monitorsToPages, 10 + notification, 11 + notificationsToMonitors, 10 12 page, 11 13 user, 12 14 usersToWorkspaces, ··· 43 45 description: "OpenStatus website", 44 46 method: "POST", 45 47 periodicity: "1m", 46 - regions: "fra1", 48 + regions: "ams", 47 49 headers: '[{"key":"key", "value":"value"}]', 48 50 body: '{"hello":"world"}', 49 51 }, ··· 54 56 periodicity: "10m", 55 57 url: "https://www.google.com", 56 58 method: "GET", 57 - regions: "fra1", 59 + regions: "gru", 58 60 }, 59 61 ]) 60 62 .run(); ··· 90 92 .run(); 91 93 92 94 await db.insert(monitorsToPages).values({ monitorId: 1, pageId: 1 }).run(); 95 + await db 96 + .insert(notification) 97 + .values({ 98 + id: 1, 99 + provider: "email", 100 + name: "sample test notification", 101 + data: '{"email":"ping@openstatus.dev"}', 102 + workspaceId: 1, 103 + }) 104 + .run(); 105 + await db 106 + .insert(notificationsToMonitors) 107 + .values({ monitorId: 1, notificationId: 1 }) 108 + .run(); 93 109 process.exit(0); 94 110 } 95 111
+1 -1
packages/emails/package.json
··· 15 15 "@react-email/head": "0.0.5", 16 16 "@react-email/html": "0.0.4", 17 17 "@react-email/tailwind": "0.0.9", 18 - "@t3-oss/env-core": "0.6.1", 18 + "@t3-oss/env-core": "0.7.0", 19 19 "react-email": "1.9.5", 20 20 "resend": "1.1.0", 21 21 "zod": "3.22.2"
+1 -1
packages/integrations/vercel/package.json
··· 5 5 "description": "Log drains Vercel integration.", 6 6 "dependencies": { 7 7 "@openstatus/tinybird": "workspace:*", 8 - "@t3-oss/env-core": "0.6.1", 8 + "@t3-oss/env-core": "0.7.0", 9 9 "react": "18.2.0", 10 10 "react-dom": "18.2.0", 11 11 "zod": "^3.22.2"
+1
packages/notifications/email/env.ts
··· 8 8 runtimeEnv: { 9 9 RESEND_API_KEY: process.env.RESEND_API_KEY, 10 10 }, 11 + skipValidation: process.env.NODE_ENV === "test", 11 12 });
+1 -1
packages/notifications/email/package.json
··· 9 9 "@openstatus/tinybird": "workspace:*", 10 10 "@react-email/components": "0.0.7", 11 11 "@react-email/render": "0.0.7", 12 - "@t3-oss/env-core": "0.6.1", 12 + "@t3-oss/env-core": "0.7.0", 13 13 "resend": "1.1.0", 14 14 "zod": "3.22.2" 15 15 },
+1 -1
packages/notifications/email/src/index.ts
··· 15 15 monitor: z.infer<typeof basicMonitorSchema>; 16 16 notification: z.infer<typeof selectNotificationSchema>; 17 17 }) => { 18 - const config = EmailConfigurationSchema.parse(notification.data); 18 + const config = EmailConfigurationSchema.parse(JSON.parse(notification.data)); 19 19 const { email } = config; 20 20 21 21 const res = await fetch("https://api.resend.com/emails", {
+1 -3
packages/plans/package.json
··· 3 3 "version": "1.0.0", 4 4 "description": "", 5 5 "main": "index.ts", 6 - "scripts": { 7 - "test": "echo \"Error: no test specified\" && exit 1" 8 - }, 6 + "scripts": {}, 9 7 "dependencies": { 10 8 "@openstatus/db": "workspace:*", 11 9 "zod": "3.22.2"
+1
packages/react/package.json
··· 9 9 ], 10 10 "license": "MIT", 11 11 "scripts": { 12 + "dev":"tsup && pnpm run build:tailwind", 12 13 "build:tailwind": "tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify", 13 14 "build": "tsup && pnpm run build:tailwind" 14 15 },
+6 -8
packages/tinybird/src/client.ts
··· 1 - import type { Tinybird } from "@chronark/zod-bird"; 1 + import { Tinybird } from "@chronark/zod-bird"; 2 2 3 3 import { 4 4 tbBuildHomeStats, ··· 11 11 } from "./validation"; 12 12 13 13 // REMINDER: 14 - // const tb = new Tinybird({ token: process.env.TINYBIRD_TOKEN! }); 14 + const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY! }); 15 15 16 - export function publishPingResponse(tb: Tinybird) { 17 - return tb.buildIngestEndpoint({ 18 - datasource: "ping_response__v4", 19 - event: tbIngestPingResponse, 20 - }); 21 - } 16 + export const publishPingResponse = tb.buildIngestEndpoint({ 17 + datasource: "ping_response__v4", 18 + event: tbIngestPingResponse, 19 + }); 22 20 23 21 export function getResponseList(tb: Tinybird) { 24 22 return tb.buildPipe({
+6 -5
packages/tinybird/src/validation.ts
··· 1 1 import * as z from "zod"; 2 2 3 - /** 4 - * All available Vercel (AWS) regions 5 - */ 6 - export const availableRegions = [ 3 + export const vercelRegions = [ 7 4 "arn1", 8 5 "bom1", 9 6 "cdg1", ··· 24 21 "syd1", 25 22 ] as const; 26 23 24 + export const flyRegions = ["ams", "iad", "hkg", "jnb", "syd", "gru"] as const; 25 + 26 + export const availableRegions = [...vercelRegions, ...flyRegions] as const; 27 + 27 28 /** 28 29 * Values for the datasource ping_response__v4 29 30 */ ··· 33 34 monitorId: z.string(), 34 35 timestamp: z.number().int(), 35 36 statusCode: z.number().int(), 36 - latency: z.number().int(), // in ms 37 + latency: z.number(), // in ms 37 38 cronTimestamp: z.number().int().optional().nullable().default(Date.now()), 38 39 url: z.string().url(), 39 40 region: z.string().min(4).max(4), // REMINDER: won't work on fy
+50 -30
pnpm-lock.yaml
··· 80 80 dependencies: 81 81 '@hono/zod-openapi': 82 82 specifier: 0.7.1 83 - version: 0.7.1(hono@3.7.3)(zod@3.22.2) 83 + version: 0.7.1(hono@3.7.6)(zod@3.22.2) 84 84 '@openstatus/db': 85 85 specifier: workspace:* 86 86 version: link:../../packages/db 87 + '@openstatus/notification-emails': 88 + specifier: workspace:* 89 + version: link:../../packages/notifications/email 87 90 '@openstatus/plans': 88 91 specifier: workspace:* 89 92 version: link:../../packages/plans ··· 93 96 '@openstatus/upstash': 94 97 specifier: workspace:* 95 98 version: link:../../packages/upstash 99 + '@t3-oss/env-core': 100 + specifier: 0.7.0 101 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 96 102 '@unkey/api': 97 103 specifier: 0.10.0 98 104 version: 0.10.0 99 105 hono: 100 - specifier: 3.7.3 101 - version: 3.7.3 106 + specifier: 3.7.6 107 + version: 3.7.6 108 + nanoid: 109 + specifier: 5.0.1 110 + version: 5.0.1 102 111 zod: 103 112 specifier: 3.22.2 104 113 version: 3.22.2 ··· 106 115 '@openstatus/tsconfig': 107 116 specifier: workspace:* 108 117 version: link:../../packages/tsconfig 118 + dotenv: 119 + specifier: 16.3.1 120 + version: 16.3.1 121 + vitest: 122 + specifier: 0.34.6 123 + version: 0.34.6 109 124 110 125 apps/web: 111 126 dependencies: ··· 161 176 specifier: 2.1.6 162 177 version: 2.1.6 163 178 '@t3-oss/env-nextjs': 164 - specifier: 0.6.1 165 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 179 + specifier: 0.7.0 180 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 166 181 '@tailwindcss/typography': 167 182 specifier: 0.5.10 168 183 version: 0.5.10(tailwindcss@3.3.2) ··· 339 354 specifier: 1.3.0 340 355 version: 1.3.0(@types/dlv@1.1.2) 341 356 '@t3-oss/env-core': 342 - specifier: 0.6.1 343 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 357 + specifier: 0.7.0 358 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 344 359 zod: 345 360 specifier: 3.22.2 346 361 version: 3.22.2 ··· 373 388 specifier: workspace:* 374 389 version: link:../plans 375 390 '@t3-oss/env-core': 376 - specifier: 0.6.1 377 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 391 + specifier: 0.7.0 392 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 378 393 '@trpc/client': 379 394 specifier: 10.38.5 380 395 version: 10.38.5(@trpc/server@10.38.5) ··· 447 462 specifier: 0.3.4 448 463 version: 0.3.4(bufferutil@4.0.7)(encoding@0.1.13)(utf-8-validate@6.0.3) 449 464 '@t3-oss/env-core': 450 - specifier: 0.6.1 451 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 465 + specifier: 0.7.0 466 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 452 467 dotenv: 453 468 specifier: 16.3.1 454 469 version: 16.3.1 ··· 502 517 specifier: 0.0.9 503 518 version: 0.0.9 504 519 '@t3-oss/env-core': 505 - specifier: 0.6.1 506 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 520 + specifier: 0.7.0 521 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 507 522 react-email: 508 523 specifier: 1.9.5 509 524 version: 1.9.5 ··· 536 551 specifier: workspace:* 537 552 version: link:../../tinybird 538 553 '@t3-oss/env-core': 539 - specifier: 0.6.1 540 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 554 + specifier: 0.7.0 555 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 541 556 react: 542 557 specifier: 18.2.0 543 558 version: 18.2.0 ··· 585 600 specifier: 0.0.7 586 601 version: 0.0.7 587 602 '@t3-oss/env-core': 588 - specifier: 0.6.1 589 - version: 0.6.1(typescript@5.2.2)(zod@3.22.2) 603 + specifier: 0.7.0 604 + version: 0.7.0(typescript@5.2.2)(zod@3.22.2) 590 605 resend: 591 606 specifier: 1.1.0 592 607 version: 1.1.0 ··· 2126 2141 tailwindcss: 3.3.2 2127 2142 dev: false 2128 2143 2129 - /@hono/zod-openapi@0.7.1(hono@3.7.3)(zod@3.22.2): 2144 + /@hono/zod-openapi@0.7.1(hono@3.7.6)(zod@3.22.2): 2130 2145 resolution: {integrity: sha512-zs6AURALRF0A1gkuY5iKmRBT7d0JsLC3xVPDjBdRbJqNV3XQv4i2kprZTRAcI9AjjGApTsZbLZv1pZRCB2viwQ==} 2131 2146 engines: {node: '>=16.0.0'} 2132 2147 peerDependencies: ··· 2134 2149 zod: 3.* 2135 2150 dependencies: 2136 2151 '@asteasolutions/zod-to-openapi': 5.5.0(zod@3.22.2) 2137 - '@hono/zod-validator': 0.1.9(hono@3.7.3)(zod@3.22.2) 2138 - hono: 3.7.3 2152 + '@hono/zod-validator': 0.1.9(hono@3.7.6)(zod@3.22.2) 2153 + hono: 3.7.6 2139 2154 zod: 3.22.2 2140 2155 dev: false 2141 2156 2142 - /@hono/zod-validator@0.1.9(hono@3.7.3)(zod@3.22.2): 2157 + /@hono/zod-validator@0.1.9(hono@3.7.6)(zod@3.22.2): 2143 2158 resolution: {integrity: sha512-qEG5xagKzyif283ldCKzp+aF9Aebclg0sfrgyRQQNAizmXpicZ3UGduST/Jp+a9bjt3mI+VyEXMftb4rogLxQA==} 2144 2159 peerDependencies: 2145 2160 hono: 3.* 2146 2161 zod: ^3.19.1 2147 2162 dependencies: 2148 - hono: 3.7.3 2163 + hono: 3.7.6 2149 2164 zod: 3.22.2 2150 2165 dev: false 2151 2166 ··· 5065 5080 dependencies: 5066 5081 tslib: 2.6.2 5067 5082 5068 - /@t3-oss/env-core@0.6.1(typescript@5.2.2)(zod@3.22.2): 5069 - resolution: {integrity: sha512-KQD7qEDJtkWIWWmTVjNvk0wnHpkvAQ6CRbUxbWMFNG/fiosBQDQvtRpBNu6USxBscJCoC4z6y7P9MN52/mLOzw==} 5083 + /@t3-oss/env-core@0.7.0(typescript@5.2.2)(zod@3.22.2): 5084 + resolution: {integrity: sha512-cgunN82CqgQOOyuMOK/bGtujX5/ooXQTwGMJVWI6mCowq6WE5EgRLOqF/DRcrElI0gSFGA9i9GrxzCnk73HZLQ==} 5070 5085 peerDependencies: 5071 5086 typescript: '>=4.7.2' 5072 5087 zod: ^3.0.0 5088 + peerDependenciesMeta: 5089 + typescript: 5090 + optional: true 5073 5091 dependencies: 5074 5092 typescript: 5.2.2 5075 5093 zod: 3.22.2 5076 5094 dev: false 5077 5095 5078 - /@t3-oss/env-nextjs@0.6.1(typescript@5.2.2)(zod@3.22.2): 5079 - resolution: {integrity: sha512-z1dIC++Vxj9kmzX5nSPfcrCSkszy3dTEPC4Ssx7Ap5AqR3c2Qa7S0xf8axn6coy7D/vCXDAAnHYnCMDhtcY3SQ==} 5096 + /@t3-oss/env-nextjs@0.7.0(typescript@5.2.2)(zod@3.22.2): 5097 + resolution: {integrity: sha512-rjQIt6P3tac2eRx4BNQLNaJ+AIb2P8wXw4uFvYbEekqMGShikkUALnX3hUn1twYiGVGHXRm6UbU+LqtjIktuGg==} 5080 5098 peerDependencies: 5081 5099 typescript: '>=4.7.2' 5082 5100 zod: ^3.0.0 5101 + peerDependenciesMeta: 5102 + typescript: 5103 + optional: true 5083 5104 dependencies: 5084 - '@t3-oss/env-core': 0.6.1(typescript@5.2.2)(zod@3.22.2) 5105 + '@t3-oss/env-core': 0.7.0(typescript@5.2.2)(zod@3.22.2) 5085 5106 typescript: 5.2.2 5086 5107 zod: 3.22.2 5087 5108 dev: false ··· 7582 7603 /dotenv@16.3.1: 7583 7604 resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} 7584 7605 engines: {node: '>=12'} 7585 - dev: false 7586 7606 7587 7607 /drange@1.1.1: 7588 7608 resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==} ··· 9261 9281 react-is: 16.13.1 9262 9282 dev: false 9263 9283 9264 - /hono@3.7.3: 9265 - resolution: {integrity: sha512-BQHdLPXb30hQ9k+04byeSi4QMHk20U1GUq0nT5kGUCGZtxeYhAS7mUJ1wgjn4SCvgiw1rcc6oBOAlwJQ7jQymA==} 9284 + /hono@3.7.6: 9285 + resolution: {integrity: sha512-nuLNH9+nV6ojXK6b9I0RGZgdMuLTOXeQPs6xsIw/G5iyT8j8m7Nnx8pTxQprmSmCaEYIPv91rCcq45PQNfW68A==} 9266 9286 engines: {node: '>=16.0.0'} 9267 9287 dev: false 9268 9288
+4 -2
turbo.json
··· 23 23 }, 24 24 "lint": {}, 25 25 "dev": { 26 - "cache": false, 27 - "persistent": true 26 + "cache": false 27 + }, 28 + "test": { 29 + "cache": false 28 30 } 29 31 } 30 32 }