refactor tier 3
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@pingql/status",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"elysia": "^1.4.27",
|
||||
"eta": "^4.5.1",
|
||||
"postgres": "^3.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.10",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Password gate for protected status pages. We sign a short-lived cookie with
|
||||
// the page id + a secret so a successful password unlock survives across page
|
||||
// loads without us having to hit Postgres on every request.
|
||||
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
const SECRET = process.env.STATUS_COOKIE_SECRET ?? process.env.MONITOR_TOKEN ?? "dev-secret-change-me";
|
||||
const COOKIE = "pingql_status_auth";
|
||||
const TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
function sign(payload: string): string {
|
||||
return createHmac("sha256", SECRET).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export function makeAuthCookie(pageId: string): string {
|
||||
const exp = Date.now() + TTL_MS;
|
||||
const payload = `${pageId}.${exp}`;
|
||||
const sig = sign(payload);
|
||||
const value = `${payload}.${sig}`;
|
||||
return `${COOKIE}=${value}; Path=/; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax${process.env.NODE_ENV !== "development" ? "; Secure" : ""}`;
|
||||
}
|
||||
|
||||
export function verifyAuthCookie(cookieHeader: string | null | undefined, pageId: string): boolean {
|
||||
if (!cookieHeader) return false;
|
||||
const match = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
|
||||
if (!match) return false;
|
||||
const value = match.slice(COOKIE.length + 1);
|
||||
const lastDot = value.lastIndexOf(".");
|
||||
if (lastDot < 0) return false;
|
||||
const payload = value.slice(0, lastDot);
|
||||
const sig = value.slice(lastDot + 1);
|
||||
const [id, expStr] = payload.split(".");
|
||||
if (id !== pageId) return false;
|
||||
const exp = Number(expStr);
|
||||
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||
const expected = sign(payload);
|
||||
if (expected.length !== sig.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPassword(plain: string, hash: string): Promise<boolean> {
|
||||
return await Bun.password.verify(plain, hash);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Tiny in-memory TTL cache keyed by string. Status pages serve the same payload
|
||||
// to many visitors during an outage; we don't want every page hit to fan out to
|
||||
// Postgres. The cache is per-process; behind a load balancer each replica fills
|
||||
// independently, which is fine — short TTLs converge quickly.
|
||||
|
||||
interface Entry<T> { value: T; expires: number }
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
const entry = store.get(key) as Entry<T> | undefined;
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expires) {
|
||||
store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
export function cacheSet<T>(key: string, value: T, ttlSeconds: number): void {
|
||||
// Soft cap so a runaway path can't blow memory. LRU-ish: oldest entries get
|
||||
// dropped first by insertion order (Map preserves it).
|
||||
if (store.size > 5000) {
|
||||
const firstKey = store.keys().next().value;
|
||||
if (firstKey) store.delete(firstKey);
|
||||
}
|
||||
store.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||
}
|
||||
|
||||
// Convenience wrapper: get-or-fill. The producer runs at most once per key
|
||||
// during the TTL window across this process.
|
||||
export async function cached<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T> {
|
||||
const hit = cacheGet<T>(key);
|
||||
if (hit !== null) return hit;
|
||||
const value = await producer();
|
||||
cacheSet(key, value, ttlSeconds);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Loads the read-only data needed to render a public status page. NEVER reads
|
||||
// the raw `pings` table — uses `monitor_region_state` for current state and
|
||||
// `monitor_uptime_rollup` for historical uptime windows.
|
||||
|
||||
import sql from "./db";
|
||||
|
||||
export type Window = "24h" | "7d" | "30d" | "90d";
|
||||
export type BucketType = "hourly" | "daily" | "weekly";
|
||||
|
||||
const WINDOW_TO_BUCKET: Record<Window, { bucket: BucketType; count: number }> = {
|
||||
"24h": { bucket: "hourly", count: 24 },
|
||||
"7d": { bucket: "daily", count: 7 },
|
||||
"30d": { bucket: "daily", count: 30 },
|
||||
"90d": { bucket: "weekly", count: 13 },
|
||||
};
|
||||
|
||||
export interface StatusPageRow {
|
||||
id: string;
|
||||
account_id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
theme: "auto" | "light" | "dark";
|
||||
password_hash: string | null;
|
||||
index_search: boolean;
|
||||
show_powered_by: boolean;
|
||||
show_response_time:boolean;
|
||||
show_cert_expiry: boolean;
|
||||
default_window: Window;
|
||||
custom_css: string | null;
|
||||
footer_text: string | null;
|
||||
og_image_url: string | null;
|
||||
analytics_html: string | null;
|
||||
auto_refresh_s: number;
|
||||
}
|
||||
|
||||
export interface MonitorRow {
|
||||
id: string;
|
||||
display_name: string;
|
||||
url: string;
|
||||
group_id: string | null;
|
||||
position: number;
|
||||
current_state: "up" | "down" | "unknown";
|
||||
region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>;
|
||||
uptime_pct: number | null; // for the page's default_window
|
||||
buckets: Array<{ start: string; total: number; up: number }>; // bar chart input
|
||||
avg_latency: number | null;
|
||||
latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>;
|
||||
}
|
||||
|
||||
export interface GroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface IncidentSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
pinned: boolean;
|
||||
started_at: string;
|
||||
resolved_at: string | null;
|
||||
latest_update_html: string | null;
|
||||
}
|
||||
|
||||
export async function loadStatusPage(slug: string): Promise<StatusPageRow | null> {
|
||||
const [row] = await sql<StatusPageRow[]>`SELECT * FROM status_pages WHERE slug = ${slug}`;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function loadGroups(pageId: string): Promise<GroupRow[]> {
|
||||
return sql<GroupRow[]>`
|
||||
SELECT id, name, position FROM status_page_groups
|
||||
WHERE status_page_id = ${pageId}
|
||||
ORDER BY position ASC, name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function loadMonitors(pageId: string, window: Window): Promise<MonitorRow[]> {
|
||||
// Step 1: page → monitors with display overrides + group + position.
|
||||
const monitorRows = await sql<any[]>`
|
||||
SELECT
|
||||
spm.monitor_id AS id,
|
||||
COALESCE(spm.display_name, m.name) AS display_name,
|
||||
m.url,
|
||||
spm.group_id,
|
||||
spm.position
|
||||
FROM status_page_monitors spm
|
||||
JOIN monitors m ON m.id = spm.monitor_id
|
||||
WHERE spm.status_page_id = ${pageId}
|
||||
ORDER BY spm.position ASC, m.name ASC
|
||||
`;
|
||||
if (monitorRows.length === 0) return [];
|
||||
|
||||
const ids = monitorRows.map((r) => r.id);
|
||||
|
||||
// Step 2: per-region current state for these monitors.
|
||||
const stateRows = await sql<{ monitor_id: string; region: string; last_state: string | null; updated_at: string }[]>`
|
||||
SELECT monitor_id, region, last_state, updated_at
|
||||
FROM monitor_region_state
|
||||
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||
`;
|
||||
const stateByMonitor: Record<string, MonitorRow["region_states"]> = {};
|
||||
for (const s of stateRows) {
|
||||
if (!stateByMonitor[s.monitor_id]) stateByMonitor[s.monitor_id] = [];
|
||||
stateByMonitor[s.monitor_id]!.push({
|
||||
region: s.region,
|
||||
state: (s.last_state as any) ?? "unknown",
|
||||
updated_at: s.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: uptime rollup buckets covering the requested window.
|
||||
const { bucket, count } = WINDOW_TO_BUCKET[window];
|
||||
const truncUnit = bucket === "hourly" ? "hour" : bucket === "daily" ? "day" : "week";
|
||||
const intervalLiteral = `${count} ${truncUnit}s`;
|
||||
const rollupRows = await sql<any[]>`
|
||||
SELECT monitor_id, bucket_start, sum(total)::int AS total, sum(up_count)::int AS up_count, avg(avg_latency)::real AS avg_latency
|
||||
FROM monitor_uptime_rollup
|
||||
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||
AND bucket_type = ${bucket}
|
||||
AND bucket_start > date_trunc(${truncUnit}, now()) - ${intervalLiteral}::interval
|
||||
GROUP BY monitor_id, bucket_start
|
||||
ORDER BY monitor_id, bucket_start ASC
|
||||
`;
|
||||
const bucketsByMonitor: Record<string, MonitorRow["buckets"]> = {};
|
||||
const latencyByMonitor: Record<string, { sum: number; n: number }> = {};
|
||||
for (const r of rollupRows) {
|
||||
if (!bucketsByMonitor[r.monitor_id]) bucketsByMonitor[r.monitor_id] = [];
|
||||
bucketsByMonitor[r.monitor_id]!.push({
|
||||
start: r.bucket_start instanceof Date ? r.bucket_start.toISOString() : String(r.bucket_start),
|
||||
total: r.total,
|
||||
up: r.up_count,
|
||||
});
|
||||
if (r.avg_latency != null) {
|
||||
const acc = latencyByMonitor[r.monitor_id] ?? { sum: 0, n: 0 };
|
||||
acc.sum += r.avg_latency * r.total;
|
||||
acc.n += r.total;
|
||||
latencyByMonitor[r.monitor_id] = acc;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: tiny recent latency history for the sparkline (last 30 hourly buckets).
|
||||
const latRows = await sql<any[]>`
|
||||
SELECT monitor_id, region, bucket_start, avg_latency
|
||||
FROM monitor_uptime_rollup
|
||||
WHERE monitor_id = ANY(${sql.array(ids)}::text[])
|
||||
AND bucket_type = 'hourly'
|
||||
AND bucket_start > now() - interval '30 hours'
|
||||
ORDER BY monitor_id, bucket_start ASC
|
||||
`;
|
||||
const latencyByMonitorList: Record<string, MonitorRow["latency_history"]> = {};
|
||||
for (const r of latRows) {
|
||||
if (!latencyByMonitorList[r.monitor_id]) latencyByMonitorList[r.monitor_id] = [];
|
||||
latencyByMonitorList[r.monitor_id]!.push({
|
||||
region: r.region,
|
||||
latency_ms: r.avg_latency != null ? Math.round(r.avg_latency) : null,
|
||||
ts: r.bucket_start instanceof Date ? r.bucket_start.toISOString() : String(r.bucket_start),
|
||||
});
|
||||
}
|
||||
|
||||
return monitorRows.map((m) => {
|
||||
const region_states = stateByMonitor[m.id] ?? [];
|
||||
let current_state: MonitorRow["current_state"] = "unknown";
|
||||
if (region_states.length > 0) {
|
||||
const anyDown = region_states.some((s) => s.state === "down");
|
||||
const anyUp = region_states.some((s) => s.state === "up");
|
||||
current_state = anyDown ? "down" : anyUp ? "up" : "unknown";
|
||||
}
|
||||
const buckets = bucketsByMonitor[m.id] ?? [];
|
||||
let uptime_pct: number | null = null;
|
||||
if (buckets.length > 0) {
|
||||
const tot = buckets.reduce((a, b) => a + b.total, 0);
|
||||
const upT = buckets.reduce((a, b) => a + b.up, 0);
|
||||
uptime_pct = tot > 0 ? +(100 * upT / tot).toFixed(2) : null;
|
||||
}
|
||||
const latAcc = latencyByMonitor[m.id];
|
||||
const avg_latency = latAcc && latAcc.n > 0 ? Math.round(latAcc.sum / latAcc.n) : null;
|
||||
return {
|
||||
id: m.id,
|
||||
display_name: m.display_name,
|
||||
url: m.url,
|
||||
group_id: m.group_id,
|
||||
position: m.position,
|
||||
current_state,
|
||||
region_states,
|
||||
uptime_pct,
|
||||
buckets,
|
||||
avg_latency,
|
||||
latency_history: latencyByMonitorList[m.id] ?? [],
|
||||
} as MonitorRow;
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadIncidents(pageId: string): Promise<{ active: IncidentSummary[]; recent: IncidentSummary[] }> {
|
||||
const incidents = await sql<any[]>`
|
||||
SELECT i.*
|
||||
FROM incidents i
|
||||
JOIN incident_status_pages isp ON isp.incident_id = i.id
|
||||
WHERE isp.status_page_id = ${pageId}
|
||||
ORDER BY i.started_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
if (incidents.length === 0) return { active: [], recent: [] };
|
||||
|
||||
const ids = incidents.map((i) => i.id);
|
||||
// Latest update html per incident.
|
||||
const latestUpdates = await sql<any[]>`
|
||||
SELECT DISTINCT ON (incident_id) incident_id, body_html, status, created_at
|
||||
FROM incident_updates
|
||||
WHERE incident_id = ANY(${sql.array(ids)}::uuid[])
|
||||
ORDER BY incident_id, created_at DESC
|
||||
`;
|
||||
const latestByIncident: Record<string, string> = {};
|
||||
for (const u of latestUpdates) latestByIncident[u.incident_id] = u.body_html;
|
||||
|
||||
const enriched: IncidentSummary[] = incidents.map((i) => ({
|
||||
id: i.id,
|
||||
title: i.title,
|
||||
status: i.status,
|
||||
severity: i.severity,
|
||||
pinned: i.pinned,
|
||||
started_at: i.started_at instanceof Date ? i.started_at.toISOString() : String(i.started_at),
|
||||
resolved_at: i.resolved_at ? (i.resolved_at instanceof Date ? i.resolved_at.toISOString() : String(i.resolved_at)) : null,
|
||||
latest_update_html: latestByIncident[i.id] ?? null,
|
||||
}));
|
||||
|
||||
const active = enriched.filter((i) => i.pinned && !i.resolved_at);
|
||||
const recent = enriched.filter((i) => !active.includes(i));
|
||||
return { active, recent };
|
||||
}
|
||||
|
||||
export interface PagePayload {
|
||||
page: Omit<StatusPageRow, "password_hash"> & { has_password: boolean };
|
||||
groups: GroupRow[];
|
||||
monitors: MonitorRow[];
|
||||
incidents: { active: IncidentSummary[]; recent: IncidentSummary[] };
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export async function loadPagePayload(slug: string, window?: Window): Promise<PagePayload | null> {
|
||||
const page = await loadStatusPage(slug);
|
||||
if (!page) return null;
|
||||
const win = (window ?? page.default_window) as Window;
|
||||
const [groups, monitors, incidents] = await Promise.all([
|
||||
loadGroups(page.id),
|
||||
loadMonitors(page.id, win),
|
||||
loadIncidents(page.id),
|
||||
]);
|
||||
const { password_hash, ...publicPage } = page;
|
||||
return {
|
||||
page: { ...publicPage, has_password: !!password_hash },
|
||||
groups,
|
||||
monitors,
|
||||
incidents,
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Read-only Postgres client. The status service does NOT run migrations —
|
||||
// schema is owned by apps/api. This file just opens a connection.
|
||||
import postgres from "postgres";
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", {
|
||||
max: 10,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
export default sql;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { Eta } from "eta";
|
||||
import { resolve } from "path";
|
||||
import sql from "./db";
|
||||
import { loadStatusPage, loadPagePayload, type Window } from "./data";
|
||||
import { renderRss } from "./render/rss";
|
||||
import { renderBadge, badgeFromState } from "./render/badge";
|
||||
import { cached } from "./cache";
|
||||
import { allow } from "./rate-limit";
|
||||
import { checkPassword, makeAuthCookie, verifyAuthCookie } from "./auth";
|
||||
|
||||
// Crash isolation: log loudly, never exit. Status pages going down silently is
|
||||
// worse than weird logs.
|
||||
process.on("unhandledRejection", (reason) => console.error("[unhandledRejection]", reason));
|
||||
process.on("uncaughtException", (err) => console.error("[uncaughtException]", err));
|
||||
|
||||
const eta = new Eta({ views: resolve(import.meta.dir, "./views"), cache: true, defaultExtension: ".ejs" });
|
||||
|
||||
const PUBLIC_BASE = process.env.STATUS_BASE_URL ?? "https://status.pingql.com";
|
||||
|
||||
function clientIp(req: Request): string {
|
||||
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||
|| req.headers.get("cf-connecting-ip")
|
||||
|| "unknown";
|
||||
}
|
||||
|
||||
function notFound(): Response {
|
||||
return new Response(eta.render("not-found", {}), {
|
||||
status: 404,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
function rateLimited(): Response {
|
||||
return new Response("Too many requests", { status: 429 });
|
||||
}
|
||||
|
||||
function isAuthorised(page: { id: string; password_hash: string | null }, req: Request): boolean {
|
||||
if (!page.password_hash) return true;
|
||||
return verifyAuthCookie(req.headers.get("cookie"), page.id);
|
||||
}
|
||||
|
||||
const app = new Elysia()
|
||||
.get("/", () => new Response("PingQL status service", {
|
||||
headers: { "content-type": "text/plain" },
|
||||
}))
|
||||
|
||||
// Public HTML page
|
||||
.get("/:slug", async ({ params, request, set }) => {
|
||||
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||
const page = await cached(`page:${params.slug}`, 60, () => loadStatusPage(params.slug));
|
||||
if (!page) return notFound();
|
||||
if (!isAuthorised(page, request)) {
|
||||
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: null }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
const payload = await cached(`payload:${params.slug}`, 60, () => loadPagePayload(params.slug));
|
||||
if (!payload) return notFound();
|
||||
|
||||
const html = eta.render("page", payload);
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "public, max-age=30, s-maxage=60",
|
||||
"x-frame-options":"SAMEORIGIN",
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy":"strict-origin-when-cross-origin",
|
||||
};
|
||||
if (!page.index_search) headers["x-robots-tag"] = "noindex, nofollow";
|
||||
return new Response(html, { headers });
|
||||
})
|
||||
|
||||
// Public JSON
|
||||
.get("/:slug.json", async ({ params, request, set, query }) => {
|
||||
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||
const page = await cached(`page:${params.slug}`, 60, () => loadStatusPage(params.slug));
|
||||
if (!page) { set.status = 404; return { error: "not found" }; }
|
||||
if (!isAuthorised(page, request)) { set.status = 401; return { error: "password required" }; }
|
||||
|
||||
const win = (query as any)?.window as Window | undefined;
|
||||
const cacheKey = `payload:${params.slug}:${win ?? page.default_window}`;
|
||||
const payload = await cached(cacheKey, 60, () => loadPagePayload(params.slug, win));
|
||||
if (!payload) { set.status = 404; return { error: "not found" }; }
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"cache-control": "public, max-age=30, s-maxage=60",
|
||||
...(page.index_search ? {} : { "x-robots-tag": "noindex, nofollow" }),
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Public RSS
|
||||
.get("/:slug.rss", async ({ params, request }) => {
|
||||
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||
const page = await loadStatusPage(params.slug);
|
||||
if (!page) return notFound();
|
||||
const xml = await cached(`rss:${params.slug}`, 300, () => renderRss(page, PUBLIC_BASE));
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
"content-type": "application/rss+xml; charset=utf-8",
|
||||
"cache-control": "public, max-age=300, s-maxage=300",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Public SVG badge
|
||||
.get("/:slug/badge.svg", async ({ params, request }) => {
|
||||
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||
const payload = await cached(`payload:${params.slug}`, 60, () => loadPagePayload(params.slug));
|
||||
if (!payload) return notFound();
|
||||
const { message, color } = badgeFromState(payload.monitors);
|
||||
const svg = renderBadge("status", message, color);
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
"content-type": "image/svg+xml",
|
||||
"cache-control": "public, max-age=60, s-maxage=60",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// PWA manifest
|
||||
.get("/:slug/manifest.json", async ({ params }) => {
|
||||
const page = await loadStatusPage(params.slug);
|
||||
if (!page) return notFound();
|
||||
return new Response(JSON.stringify({
|
||||
name: page.title,
|
||||
short_name: page.title.slice(0, 12),
|
||||
description: page.description ?? "",
|
||||
start_url: `/${page.slug}`,
|
||||
display: "standalone",
|
||||
background_color: page.theme === "light" ? "#ffffff" : "#0a0a0a",
|
||||
theme_color: page.theme === "light" ? "#0ea5e9" : "#0a0a0a",
|
||||
}), {
|
||||
headers: {
|
||||
"content-type": "application/manifest+json",
|
||||
"cache-control": "public, max-age=86400, s-maxage=86400",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Password gate POST
|
||||
.post("/:slug/auth", async ({ params, request }) => {
|
||||
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||
const page = await loadStatusPage(params.slug);
|
||||
if (!page) return notFound();
|
||||
if (!page.password_hash) {
|
||||
return Response.redirect(`/${page.slug}`, 303);
|
||||
}
|
||||
const form = await request.formData();
|
||||
const password = String(form.get("password") ?? "");
|
||||
const ok = await checkPassword(password, page.password_hash);
|
||||
if (!ok) {
|
||||
return new Response(eta.render("password", { title: page.title, slug: page.slug, error: "Wrong password" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: { "location": `/${page.slug}`, "set-cookie": makeAuthCookie(page.id) },
|
||||
});
|
||||
});
|
||||
|
||||
const port = Number(process.env.STATUS_PORT ?? 3003);
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch(req) { return app.handle(req); },
|
||||
});
|
||||
|
||||
console.log(`PingQL status service running at http://localhost:${server.port}`);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Per-(slug, IP) token bucket. 30 requests in a 10s window. Cheap, in-memory,
|
||||
// resets on process restart. Behind a load balancer each replica enforces its
|
||||
// own bucket — that's fine, the goal is "stop a hostile script from melting one
|
||||
// box", not perfect distributed accounting.
|
||||
|
||||
interface Bucket { tokens: number; refillAt: number }
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const CAPACITY = 30;
|
||||
const WINDOW_MS = 10_000;
|
||||
|
||||
export function allow(slug: string, ip: string): boolean {
|
||||
const key = `${slug}\x00${ip}`;
|
||||
const now = Date.now();
|
||||
let b = buckets.get(key);
|
||||
if (!b || now > b.refillAt) {
|
||||
b = { tokens: CAPACITY, refillAt: now + WINDOW_MS };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
if (b.tokens <= 0) return false;
|
||||
b.tokens--;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Periodic sweep so the map doesn't grow forever.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, b] of buckets) {
|
||||
if (now > b.refillAt + WINDOW_MS) buckets.delete(k);
|
||||
}
|
||||
}, 60_000).unref?.();
|
||||
@@ -0,0 +1,33 @@
|
||||
// Shields-style SVG badge for embedding on README files etc.
|
||||
|
||||
export function renderBadge(label: string, message: string, color: string): string {
|
||||
// Approximate text width: 6.5px per char + 10px padding each side.
|
||||
const labelW = 10 + label.length * 6.5 + 10;
|
||||
const messageW = 10 + message.length * 6.5 + 10;
|
||||
const total = labelW + messageW;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total.toFixed(0)}" height="20" role="img" aria-label="${label}: ${message}">
|
||||
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||
<clipPath id="r"><rect width="${total.toFixed(0)}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${labelW.toFixed(0)}" height="20" fill="#555"/>
|
||||
<rect x="${labelW.toFixed(0)}" width="${messageW.toFixed(0)}" height="20" fill="${color}"/>
|
||||
<rect width="${total.toFixed(0)}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="${(labelW / 2).toFixed(1)}" y="14">${escapeXml(label)}</text>
|
||||
<text x="${(labelW + messageW / 2).toFixed(1)}" y="14">${escapeXml(message)}</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function badgeFromState(monitors: Array<{ current_state: "up" | "down" | "unknown" }>): { message: string; color: string } {
|
||||
if (monitors.length === 0) return { message: "no data", color: "#9f9f9f" };
|
||||
const down = monitors.filter((m) => m.current_state === "down").length;
|
||||
if (down === 0) return { message: "operational", color: "#4c1" };
|
||||
if (down < monitors.length) return { message: "degraded", color: "#dfb317" };
|
||||
return { message: "down", color: "#e05d44" };
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// RSS 2.0 feed of incidents on a status page. Unlike Uptime Kuma we include all
|
||||
// incident lifecycle events (investigating / identified / monitoring / resolved),
|
||||
// not just initial outages, so subscribers see the full timeline.
|
||||
|
||||
import sql from "../db";
|
||||
import type { StatusPageRow } from "../data";
|
||||
|
||||
interface FeedItem {
|
||||
guid: string;
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export async function renderRss(page: StatusPageRow, baseUrl: string): Promise<string> {
|
||||
const updates = await sql<any[]>`
|
||||
SELECT iu.id, iu.status, iu.body_html, iu.created_at, i.title AS incident_title, i.id AS incident_id
|
||||
FROM incident_updates iu
|
||||
JOIN incidents i ON i.id = iu.incident_id
|
||||
JOIN incident_status_pages isp ON isp.incident_id = i.id
|
||||
WHERE isp.status_page_id = ${page.id}
|
||||
ORDER BY iu.created_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
const items: FeedItem[] = updates.map((u) => ({
|
||||
guid: `update-${u.id}`,
|
||||
title: `[${u.status}] ${u.incident_title}`,
|
||||
link: `${baseUrl}/${page.slug}#incident-${u.incident_id}`,
|
||||
pubDate: new Date(u.created_at).toUTCString(),
|
||||
body_html: u.body_html,
|
||||
}));
|
||||
|
||||
const channelTitle = escapeXml(`${page.title} — Incidents`);
|
||||
const channelDescription = escapeXml(page.description || `${page.title} status updates`);
|
||||
const channelLink = `${baseUrl}/${page.slug}`;
|
||||
|
||||
const itemsXml = items.map((it) => `
|
||||
<item>
|
||||
<guid isPermaLink="false">${it.guid}</guid>
|
||||
<title>${escapeXml(it.title)}</title>
|
||||
<link>${escapeXml(it.link)}</link>
|
||||
<pubDate>${it.pubDate}</pubDate>
|
||||
<description><![CDATA[${it.body_html}]]></description>
|
||||
</item>`).join("");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${channelTitle}</title>
|
||||
<link>${escapeXml(channelLink)}</link>
|
||||
<description>${channelDescription}</description>
|
||||
<ttl>300</ttl>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><title>Not found</title>
|
||||
<style>body { background:#0a0a0a; color:#94a3b8; font-family:-apple-system, sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; } .card{text-align:center} h1{color:#f1f5f9; margin:0 0 0.5rem; font-size:1.5rem} a{color:#38bdf8;text-decoration:none}</style>
|
||||
</head><body><div class="card"><h1>Status page not found</h1><p>The page you're looking for doesn't exist.</p><p><a href="https://pingql.com">PingQL</a></p></div></body></html>
|
||||
@@ -0,0 +1,223 @@
|
||||
<%
|
||||
const page = it.page;
|
||||
const monitors = it.monitors;
|
||||
const groups = it.groups;
|
||||
const incidents = it.incidents;
|
||||
const themeClass = page.theme === 'dark' ? 'dark' : page.theme === 'light' ? 'light' : '';
|
||||
|
||||
// Group monitors. group_id null = "ungrouped".
|
||||
const grouped = {};
|
||||
for (const m of monitors) {
|
||||
const key = m.group_id || '';
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(m);
|
||||
}
|
||||
const groupOrder = [...groups.map(g => g.id), ''];
|
||||
|
||||
function fmtPct(p) {
|
||||
if (p == null) return '—';
|
||||
return p === 100 ? '100%' : p.toFixed(2) + '%';
|
||||
}
|
||||
function statusLabel(s) {
|
||||
if (s === 'up') return 'Operational';
|
||||
if (s === 'down') return 'Down';
|
||||
return 'Unknown';
|
||||
}
|
||||
function statusColor(s) {
|
||||
if (s === 'up') return '#10b981';
|
||||
if (s === 'down') return '#ef4444';
|
||||
return '#9ca3af';
|
||||
}
|
||||
function bucketColor(b) {
|
||||
if (b.total === 0) return '#374151';
|
||||
if (b.up === b.total) return '#10b981';
|
||||
if (b.up === 0) return '#ef4444';
|
||||
return '#f59e0b';
|
||||
}
|
||||
|
||||
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
||||
let overall = 'up';
|
||||
for (const m of monitors) {
|
||||
const partial = m.region_states.some(r => r.state === 'down') && m.region_states.some(r => r.state === 'up');
|
||||
if (m.current_state === 'down') { overall = 'down'; break; }
|
||||
if (partial && overall !== 'down') overall = 'degraded';
|
||||
}
|
||||
const overallText = overall === 'up' ? 'All systems operational'
|
||||
: overall === 'degraded' ? 'Some systems degraded'
|
||||
: 'Major outage in progress';
|
||||
const overallColor = overall === 'up' ? '#10b981'
|
||||
: overall === 'degraded' ? '#f59e0b'
|
||||
: '#ef4444';
|
||||
%><!DOCTYPE html>
|
||||
<html lang="en" class="<%= themeClass %>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= page.title %></title>
|
||||
<% if (page.description) { %><meta name="description" content="<%= page.description %>"><% } %>
|
||||
<% if (!page.index_search) { %><meta name="robots" content="noindex,nofollow"><% } %>
|
||||
<meta property="og:title" content="<%= page.title %>">
|
||||
<% if (page.description) { %><meta property="og:description" content="<%= page.description %>"><% } %>
|
||||
<% if (page.og_image_url) { %><meta property="og:image" content="<%= page.og_image_url %>"><% } %>
|
||||
<link rel="alternate" type="application/rss+xml" title="<%= page.title %> incidents" href="/<%= page.slug %>.rss">
|
||||
<link rel="manifest" href="/<%= page.slug %>/manifest.json">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff; --fg: #0f172a; --muted: #64748b; --card: #f8fafc;
|
||||
--border: #e2e8f0; --accent: #0ea5e9; --green: #10b981; --red: #ef4444; --amber: #f59e0b;
|
||||
}
|
||||
html.dark, html:not(.light):not(.dark) { color-scheme: dark; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not(.light) {
|
||||
--bg: #0a0a0a; --fg: #f1f5f9; --muted: #94a3b8; --card: #111827;
|
||||
--border: #1f2937; --accent: #38bdf8;
|
||||
}
|
||||
}
|
||||
html.dark {
|
||||
--bg: #0a0a0a; --fg: #f1f5f9; --muted: #94a3b8; --card: #111827;
|
||||
--border: #1f2937; --accent: #38bdf8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; line-height: 1.5; }
|
||||
main { max-width: 880px; margin: 0 auto; padding: 3rem 1.5rem; }
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
||||
.muted { color: var(--muted); font-size: 0.875rem; }
|
||||
.overall { padding: 1.25rem 1.5rem; border-radius: 12px; color: white; font-weight: 600; font-size: 1.05rem; margin: 1.5rem 0 2rem; display: flex; align-items: center; gap: 0.75rem; }
|
||||
.overall .dot { width: 12px; height: 12px; border-radius: 50%; background: white; }
|
||||
.group-title { font-size: 0.85rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; }
|
||||
.monitors { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.monitor { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.25rem; }
|
||||
.monitor-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
.monitor-name { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||
.monitor-name .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.monitor-name .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.monitor-meta { display: flex; gap: 1rem; align-items: center; font-size: 0.85rem; color: var(--muted); }
|
||||
.uptime-pct { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg); }
|
||||
.bars { display: flex; gap: 2px; height: 32px; margin-top: 0.5rem; align-items: stretch; }
|
||||
.bar { flex: 1; min-width: 0; border-radius: 2px; }
|
||||
.regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; }
|
||||
.region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); }
|
||||
.region.up { color: var(--green); border-color: rgba(16,185,129,0.3); }
|
||||
.region.down { color: var(--red); border-color: rgba(239,68,68,0.3); }
|
||||
.incidents { margin-bottom: 2rem; }
|
||||
.incident { background: var(--card); border-left: 4px solid var(--amber); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
.incident.critical { border-left-color: var(--red); }
|
||||
.incident.major { border-left-color: var(--amber); }
|
||||
.incident-title { font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.incident-meta { color: var(--muted); font-size: 0.8rem; margin-bottom: 0.5rem; }
|
||||
.incident-body p { margin: 0.5rem 0; }
|
||||
.incident-body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.past-incidents { margin-top: 3rem; }
|
||||
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
|
||||
.past { padding: 0.75rem 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.past-title { font-weight: 500; }
|
||||
.past-meta { color: var(--muted); font-size: 0.8rem; }
|
||||
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
<% if (page.custom_css) { %><%~ page.custom_css %><% } %>
|
||||
</style>
|
||||
<% if (page.analytics_html) { %><%~ page.analytics_html %><% } %>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1><%= page.title %></h1>
|
||||
<% if (page.description) { %><div class="muted"><%= page.description %></div><% } %>
|
||||
|
||||
<div class="overall" style="background: <%= overallColor %>;">
|
||||
<span class="dot"></span>
|
||||
<span><%= overallText %></span>
|
||||
</div>
|
||||
|
||||
<% if (incidents.active.length > 0) { %>
|
||||
<div class="incidents">
|
||||
<% incidents.active.forEach(function(i) { %>
|
||||
<div id="incident-<%= i.id %>" class="incident <%= i.severity %>">
|
||||
<div class="incident-title"><%= i.title %></div>
|
||||
<div class="incident-meta"><%= i.status %> · started <%= new Date(i.started_at).toLocaleString() %></div>
|
||||
<% if (i.latest_update_html) { %><div class="incident-body"><%~ i.latest_update_html %></div><% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% groupOrder.forEach(function(gid) {
|
||||
const list = grouped[gid];
|
||||
if (!list || list.length === 0) return;
|
||||
const groupName = gid ? (groups.find(g => g.id === gid)?.name || '') : '';
|
||||
%>
|
||||
<% if (groupName) { %><div class="group-title"><%= groupName %></div><% } %>
|
||||
<div class="monitors">
|
||||
<% list.forEach(function(m) { %>
|
||||
<div class="monitor">
|
||||
<div class="monitor-head">
|
||||
<div class="monitor-name">
|
||||
<span class="dot" style="background: <%= statusColor(m.current_state) %>;"></span>
|
||||
<span class="name"><%= m.display_name %></span>
|
||||
</div>
|
||||
<div class="monitor-meta">
|
||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<span class="uptime-pct"><%= fmtPct(m.uptime_pct) %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% if (m.buckets && m.buckets.length > 0) { %>
|
||||
<div class="bars" title="<%= statusLabel(m.current_state) %>">
|
||||
<% m.buckets.forEach(function(b) { %>
|
||||
<div class="bar" style="background: <%= bucketColor(b) %>;"></div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (m.region_states && m.region_states.length > 1) { %>
|
||||
<div class="regions">
|
||||
<% m.region_states.forEach(function(r) { %>
|
||||
<span class="region <%= r.state %>"><%= r.region %></span>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% }); %>
|
||||
|
||||
<% if (incidents.recent.length > 0) { %>
|
||||
<div class="past-incidents">
|
||||
<h2>Past incidents</h2>
|
||||
<% incidents.recent.forEach(function(i) { %>
|
||||
<div class="past">
|
||||
<div>
|
||||
<div class="past-title"><%= i.title %></div>
|
||||
<div class="past-meta"><%= i.status %> · <%= new Date(i.started_at).toLocaleDateString() %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<footer>
|
||||
<% if (page.footer_text) { %><div><%= page.footer_text %></div><% } %>
|
||||
<% if (page.show_powered_by) { %><div>Status powered by <a href="https://pingql.com">PingQL</a></div><% } %>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<% if (page.auto_refresh_s > 0) { %>
|
||||
<script>
|
||||
// Auto-refresh data without a full page reload. Polls /<slug>.json and
|
||||
// patches just the bar/uptime/dot DOM nodes.
|
||||
(function() {
|
||||
const slug = <%~ JSON.stringify(page.slug) %>;
|
||||
const intervalMs = Math.max(10, <%= page.auto_refresh_s %>) * 1000;
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/' + slug + '.json', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
// Reload on next idle for simplicity. The JSON payload is already cached
|
||||
// server-side; the visible diff is small.
|
||||
location.reload();
|
||||
} catch {}
|
||||
}
|
||||
setTimeout(refresh, intervalMs);
|
||||
})();
|
||||
</script>
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title><%= it.title %> — Password required</title>
|
||||
<style>
|
||||
body { background: #0a0a0a; color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
||||
p { color: #94a3b8; font-size: 0.875rem; margin: 0 0 1.5rem; }
|
||||
input { width: 100%; padding: 0.75rem 1rem; background: #0a0a0a; border: 1px solid #374151; border-radius: 8px; color: #f1f5f9; font-size: 1rem; box-sizing: border-box; }
|
||||
input:focus { outline: none; border-color: #38bdf8; }
|
||||
button { width: 100%; margin-top: 1rem; padding: 0.75rem; background: #0ea5e9; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; }
|
||||
button:hover { background: #0284c7; }
|
||||
.err { color: #ef4444; font-size: 0.85rem; margin-top: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1><%= it.title %></h1>
|
||||
<p>This status page is password protected.</p>
|
||||
<form method="POST" action="/<%= it.slug %>/auth">
|
||||
<input type="password" name="password" placeholder="Password" autofocus required>
|
||||
<button type="submit">Unlock</button>
|
||||
<% if (it.error) { %><div class="err"><%= it.error %></div><% } %>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ESNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user