diff --git a/apps/web/src/app/[locale]/(marketing)/page.tsx b/apps/web/src/app/[locale]/(marketing)/page.tsx index 52b96e7..dad146e 100644 --- a/apps/web/src/app/[locale]/(marketing)/page.tsx +++ b/apps/web/src/app/[locale]/(marketing)/page.tsx @@ -9,8 +9,14 @@ import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard"; import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh"; import { FAQ } from "~/modules/marketing/home/faq"; import { CallToAction } from "~/modules/marketing/home/cta"; +import { MeshStats } from "~/modules/marketing/home/mesh-stats"; import { LatestNewsToaster } from "~/modules/marketing/home/toaster"; +// Revalidate the page every 60s so the mesh-stats counter stays fresh +// without hammering the DB. The /api/public/stats endpoint has its own +// 60s in-memory cache too. +export const revalidate = 60; + const HomePage = () => { return (
{ +
); diff --git a/apps/web/src/modules/marketing/home/mesh-stats.tsx b/apps/web/src/modules/marketing/home/mesh-stats.tsx new file mode 100644 index 0000000..0b59bd9 --- /dev/null +++ b/apps/web/src/modules/marketing/home/mesh-stats.tsx @@ -0,0 +1,72 @@ +import { + publicStatsResponseSchema, + type PublicStatsResponse, +} from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; + +import { api } from "~/lib/api/server"; + +const ZERO_STATS: PublicStatsResponse = { + messagesRouted: 0, + meshesCreated: 0, + peersActive: 0, + lastUpdated: new Date(0).toISOString(), +}; + +const fetchStats = async (): Promise => { + try { + return await handle(api.public.stats.$get, { + schema: publicStatsResponseSchema, + })(); + } catch { + return ZERO_STATS; + } +}; + +const nf = new Intl.NumberFormat("en-US"); + +export const MeshStats = async () => { + const stats = await fetchStats(); + const empty = stats.messagesRouted === 0; + + return ( +
+
+
+ + ciphertext routed + + + {empty ? ( + + ready to route + + ) : ( + <> + + {nf.format(stats.messagesRouted)} messages + + · + + {nf.format(stats.meshesCreated)} meshes + + · + + {nf.format(stats.peersActive)} peers online + + + )} +
+

+ broker sees none of it +

+
+
+ ); +}; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 15926da..99478e3 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -15,6 +15,7 @@ import { authRouter } from "./modules/auth/router"; import { billingRouter } from "./modules/billing/router"; import { myRouter } from "./modules/mesh/router"; import { organizationRouter } from "./modules/organization/router"; +import { publicRouter } from "./modules/public/router"; import { storageRouter } from "./modules/storage/router"; import { onError } from "./utils/on-error"; @@ -51,6 +52,7 @@ const appRouter = new Hono() .route("/billing", billingRouter) .route("/my", myRouter) .route("/organizations", organizationRouter) + .route("/public", publicRouter) .route("/storage", storageRouter) .onError(onError); diff --git a/packages/api/src/modules/public/router.ts b/packages/api/src/modules/public/router.ts new file mode 100644 index 0000000..3bb1004 --- /dev/null +++ b/packages/api/src/modules/public/router.ts @@ -0,0 +1,57 @@ +import { Hono } from "hono"; + +import { count, isNull } from "@turbostarter/db"; +import { mesh, messageQueue, presence } from "@turbostarter/db/schema"; +import { db } from "@turbostarter/db/server"; + +/** + * Unauthed public stats for the landing page counter. + * + * In-memory 60s cache. Results are aggregate counts only — no ids, + * no names, no ciphertext, no routing metadata. Safe for public consumption. + */ +const CACHE_TTL_MS = 60_000; + +interface PublicStats { + messagesRouted: number; + meshesCreated: number; + peersActive: number; + lastUpdated: string; +} + +let cachedStats: { value: PublicStats; expiresAt: number } | null = null; + +const fetchStats = async (): Promise => { + const [[messagesRouted], [meshesCreated], [peersActive]] = await Promise.all([ + db.select({ c: count() }).from(messageQueue), + db + .select({ c: count() }) + .from(mesh) + .where(isNull(mesh.archivedAt)), + db + .select({ c: count() }) + .from(presence) + .where(isNull(presence.disconnectedAt)), + ]); + + return { + messagesRouted: messagesRouted?.c ?? 0, + meshesCreated: meshesCreated?.c ?? 0, + peersActive: peersActive?.c ?? 0, + lastUpdated: new Date().toISOString(), + }; +}; + +export const publicRouter = new Hono().get("/stats", async (c) => { + const now = Date.now(); + if (cachedStats && cachedStats.expiresAt > now) { + c.header("x-cache", "HIT"); + return c.json(cachedStats.value); + } + + const value = await fetchStats(); + cachedStats = { value, expiresAt: now + CACHE_TTL_MS }; + c.header("x-cache", "MISS"); + c.header("cache-control", "public, max-age=60, s-maxage=60"); + return c.json(value); +}); diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts index 3f5db72..ae05cd5 100644 --- a/packages/api/src/schema/mesh-user.ts +++ b/packages/api/src/schema/mesh-user.ts @@ -186,6 +186,18 @@ export type GetMyMeshStreamResponse = z.infer< typeof getMyMeshStreamResponseSchema >; +// --------------------------------------------------------------------- +// Public stats (unauthed landing counter) +// --------------------------------------------------------------------- + +export const publicStatsResponseSchema = z.object({ + messagesRouted: z.number(), + meshesCreated: z.number(), + peersActive: z.number(), + lastUpdated: z.string(), +}); +export type PublicStatsResponse = z.infer; + export const getMyInvitesResponseSchema = z.object({ sent: z.array( z.object({