Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,24 @@ IMAGE_REMOTE_HOSTS=
EDGE_CACHE_MODE=none
EDGE_CACHE_TTL=60
# Purge adapter used by frontlit-api in purge mode: http (signed POST to
# EDGE_PURGE_URL). deploy/edge-cache sets the URL itself and only needs
# EDGE_PURGE_SECRET.
# EDGE_PURGE_URL) or cloudflare. deploy/edge-cache sets the URL itself and
# only needs EDGE_PURGE_SECRET.
EDGE_PURGE_ADAPTER=http
EDGE_PURGE_URL=
EDGE_PURGE_SECRET=
# EDGE_PURGE_ADAPTER=cloudflare: the zone custom hostnames live in, and an
# API token with the Zone → Cache Purge permission.
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_API_TOKEN=
# deploy/edge-cache (Varnish) only: published port and cache size.
EDGE_PORT=8080
VARNISH_SIZE=512M
# Optional separate host for /_next/static. Build time.
ASSET_PREFIX=

# Public pages send anonymous Core Web Vitals to frontlit-api's POST /rum
# (scrape /metrics; see deploy/monitoring). Set to "off" to disable.
SITE_RUM=
INTEGRATIONS_ENCRYPTION_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Expand Down
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ SITES_REVALIDATE_URL=
EDGE_PURGE_ADAPTER=http
EDGE_PURGE_URL=
EDGE_PURGE_SECRET=
# EDGE_PURGE_ADAPTER=cloudflare (hosted FrontLit): the zone custom hostnames
# live in and a token with the Zone → Cache Purge permission.
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_API_TOKEN=

# AI providers and credentials are configured per team in Settings → AI.

Expand Down
44 changes: 42 additions & 2 deletions apps/api/src/metrics/routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Router } from "express";
import express, { Router } from "express";
import { renderCounters } from "../services/metrics";
import { getOutboxQueueStats } from "../email/outbox";
import { siteCacheOutboxStats } from "../services/site-cache";
import {
allowRumBeacon,
recordRumBeacon,
renderRumMetrics,
rumBeaconSchema,
} from "../services/rum";

/**
* Prometheus-format metrics (architecture.md §13.6). Deliberately outside
Expand All @@ -12,20 +19,53 @@ import { getOutboxQueueStats } from "../email/outbox";
const router = Router();

router.get("/metrics", async (_req, res) => {
const { pendingCount, oldestPendingAgeSeconds } = await getOutboxQueueStats();
const [{ pendingCount, oldestPendingAgeSeconds }, siteCache] = await Promise.all([
getOutboxQueueStats(),
siteCacheOutboxStats(),
]);

const lines = [
renderCounters(),
renderRumMetrics(),
"# TYPE outbox_queue_pending gauge",
`outbox_queue_pending ${pendingCount}`,
"# TYPE outbox_queue_oldest_pending_age_seconds gauge",
...(oldestPendingAgeSeconds !== null
? [`outbox_queue_oldest_pending_age_seconds ${oldestPendingAgeSeconds}`]
: []),
"# HELP site_cache_invalidations_pending Public-site cache invalidations not yet delivered everywhere",
"# TYPE site_cache_invalidations_pending gauge",
`site_cache_invalidations_pending ${siteCache.pending}`,
"# TYPE site_cache_invalidations_oldest_age_seconds gauge",
...(siteCache.oldestSeconds !== null
? [
`site_cache_invalidations_oldest_age_seconds ${Math.round(Number(siteCache.oldestSeconds))}`,
]
: []),
];

res.set("content-type", "text/plain; version=0.0.4; charset=utf-8");
res.send(lines.filter(Boolean).join("\n") + "\n");
});

/**
* Web Vitals beacon from public-site visitors (`navigator.sendBeacon`, sent
* as text/plain so no CORS preflight is needed). Anonymous and cheap: it
* only updates in-memory histograms, so it has its own per-client limit
* instead of the database-backed public limiter.
*/
router.post("/rum", express.text({ type: "*/*", limit: "4kb" }), (req, res) => {
if (!allowRumBeacon(req.ip ?? "unknown")) return res.status(429).end();
let body: unknown;
try {
body = JSON.parse(typeof req.body === "string" ? req.body : "");
} catch {
return res.status(400).end();
}
const parsed = rumBeaconSchema.safeParse(body);
if (!parsed.success) return res.status(400).end();
recordRumBeacon(parsed.data);
res.status(204).end();
});

export default router;
53 changes: 53 additions & 0 deletions apps/api/src/services/edge-purge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ afterEach(() => {
delete process.env.EDGE_PURGE_URL;
delete process.env.EDGE_PURGE_SECRET;
delete process.env.EDGE_PURGE_ADAPTER;
delete process.env.CLOUDFLARE_ZONE_ID;
delete process.env.CLOUDFLARE_API_TOKEN;
});

function configure() {
Expand Down Expand Up @@ -78,3 +80,54 @@ describe("edge purge", () => {
expect(fetchMock).toHaveBeenCalledOnce();
});
});

describe("cloudflare adapter", () => {
function configureCloudflare() {
process.env.EDGE_PURGE_ADAPTER = "cloudflare";
process.env.CLOUDFLARE_ZONE_ID = "zone123";
process.env.CLOUDFLARE_API_TOKEN = "cf-token";
}

it("purges tags in batches of 30 with the API token", async () => {
configureCloudflare();
const fetchMock = vi.fn(
async (_url: string | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
const tags = Array.from({ length: 45 }, (_, i) => `page:team_a:p${i}`);
await deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", tags);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.cloudflare.com/client/v4/zones/zone123/purge_cache");
expect((init?.headers as Record<string, string>).authorization).toBe(
"Bearer cf-token",
);
expect(JSON.parse(String(init?.body)).tags).toHaveLength(30);
expect(JSON.parse(String(fetchMock.mock.calls[1]![1]?.body)).tags).toHaveLength(15);
});

it("fails on an unsuccessful API answer so the outbox retries", async () => {
configureCloudflare();
vi.stubGlobal(
"fetch",
async () =>
new Response(
JSON.stringify({ success: false, errors: [{ message: "rate limited" }] }),
{
status: 429,
},
),
);
await expect(
deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", ["docs:team_a"]),
).rejects.toThrow(/rate limited/);
});

it("is off until the zone and token are set", () => {
process.env.EDGE_PURGE_ADAPTER = "cloudflare";
expect(edgePurgeAdapter()).toBeNull();
configureCloudflare();
expect(edgePurgeAdapter()).not.toBeNull();
});
});
41 changes: 41 additions & 0 deletions apps/api/src/services/edge-purge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,54 @@ export function httpPurgeAdapter(url: string, secret: string): EdgePurgeAdapter
};
}

/**
* Hosted FrontLit on Cloudflare (for SaaS): purge by `Cache-Tag` in the zone
* every custom hostname is attached to. Tags carry the public team ID, so
* one team's purge never touches another's pages. Batched below
* Cloudflare's per-request tag limit.
*/
export function cloudflarePurgeAdapter(
zoneId: string,
apiToken: string,
baseUrl = "https://api.cloudflare.com/client/v4",
): EdgePurgeAdapter {
return {
async purge({ tags }) {
for (let start = 0; start < tags.length; start += 30) {
const response = await fetch(`${baseUrl}/zones/${zoneId}/purge_cache`, {
method: "POST",
headers: {
authorization: `Bearer ${apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ tags: tags.slice(start, start + 30) }),
signal: AbortSignal.timeout(10_000),
});
const result = (await response.json().catch(() => null)) as {
success?: boolean;
errors?: { message?: string }[];
} | null;
if (!response.ok || !result?.success) {
throw new Error(
`Cloudflare purge failed (${response.status}): ${result?.errors?.[0]?.message ?? "no details"}`,
);
}
}
},
};
}

/** The configured adapter, or null when no edge cache needs purging. */
export function edgePurgeAdapter(env = process.env): EdgePurgeAdapter | null {
const adapter = env.EDGE_PURGE_ADAPTER || "http";
if (adapter === "http") {
if (!env.EDGE_PURGE_URL || !env.EDGE_PURGE_SECRET) return null;
return httpPurgeAdapter(env.EDGE_PURGE_URL, env.EDGE_PURGE_SECRET);
}
if (adapter === "cloudflare") {
if (!env.CLOUDFLARE_ZONE_ID || !env.CLOUDFLARE_API_TOKEN) return null;
return cloudflarePurgeAdapter(env.CLOUDFLARE_ZONE_ID, env.CLOUDFLARE_API_TOKEN);
}
throw new Error(`Unknown EDGE_PURGE_ADAPTER "${adapter}"`);
}

Expand Down
60 changes: 60 additions & 0 deletions apps/api/src/services/rum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
allowRumBeacon,
recordRumBeacon,
renderRumMetrics,
resetRumMetrics,
rumBeaconSchema,
} from "./rum";

beforeEach(() => resetRumMetrics());

describe("RUM metrics", () => {
it("records vitals as histograms by route and cache status", () => {
recordRumBeacon(
rumBeaconSchema.parse({
metrics: [
{ name: "TTFB", value: 80 },
{ name: "LCP", value: 1200 },
{ name: "CLS", value: 0.02 },
],
route: "docs",
cache: "HIT",
}),
);
const text = renderRumMetrics();
expect(text).toContain(
'site_web_vital_bucket{metric="TTFB",route="docs",cache="HIT",le="0.05"} 0',
);
expect(text).toContain(
'site_web_vital_bucket{metric="TTFB",route="docs",cache="HIT",le="0.1"} 1',
);
expect(text).toContain(
'site_web_vital_sum{metric="LCP",route="docs",cache="HIT"} 1.2',
);
expect(text).toContain(
'site_web_vital_bucket{metric="CLS",route="docs",cache="HIT",le="0.05"} 1',
);
expect(text).toContain('site_page_loads_total{route="docs",cache="HIT"} 1');
});

it("keeps labels bounded: unknown route and cache values collapse", () => {
const beacon = rumBeaconSchema.parse({
metrics: [{ name: "TTFB", value: 10 }],
route: "/some/customer/path",
cache: "STALE",
});
expect(beacon).toMatchObject({ route: "other", cache: "unknown" });
expect(
rumBeaconSchema.safeParse({ metrics: [{ name: "FID", value: 1 }], route: "page" })
.success,
).toBe(false);
});

it("limits beacons per client and minute", () => {
for (let i = 0; i < 30; i++) expect(allowRumBeacon("1.2.3.4", 0)).toBe(true);
expect(allowRumBeacon("1.2.3.4", 0)).toBe(false);
expect(allowRumBeacon("5.6.7.8", 0)).toBe(true);
expect(allowRumBeacon("1.2.3.4", 61_000)).toBe(true);
});
});
Loading
Loading