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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ SITES_REDIS_URL=
# separated (default: cdn.medialit.cloud,images.unsplash.com). Build time:
# rebuild frontlit-sites after changing it.
IMAGE_REMOTE_HOSTS=

# --- Optional edge cache / CDN (docs/site-delivery.md §5) -------------------
# none (default): FrontLit's own cache only. ttl: any CDN or proxy in front
# may keep pages EDGE_CACHE_TTL seconds. purge: pages stay at the edge until
# frontlit-api purges them (needs EDGE_PURGE_* below).
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_ADAPTER=http
EDGE_PURGE_URL=
EDGE_PURGE_SECRET=
# 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=
INTEGRATIONS_ENCRYPTION_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Expand Down
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ REVALIDATE_WEBHOOK_SECRET=
# SITES_BASE_URL.
SITES_REVALIDATE_URL=

# Edge cache purging, when apps/sites runs with EDGE_CACHE_MODE=purge
# (docs/site-delivery.md §5, deploy/edge-cache). The http adapter POSTs
# { teamId, tags } signed with EDGE_PURGE_SECRET to EDGE_PURGE_URL.
EDGE_PURGE_ADAPTER=http
EDGE_PURGE_URL=
EDGE_PURGE_SECRET=

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

# Documentation GitHub App. See docs/docs-management.md (operations runbook).
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/documentation/projections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export function publicPurge(row: any) {
expiresAt,
createdAt,
completedAt,
deliveredAt: row.deliveredAt ?? null,
});
}
34 changes: 31 additions & 3 deletions apps/api/src/documentation/publications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { siteOriginForTeam } from "../utils/site-url";
import { wakeDocumentationWorker } from "./worker-wake";
import { deliverEdgePurge, edgeDestinations, NotYetDue } from "../services/edge-purge";
import { activateGitRelease } from "../pages/git-import";
import {
checkGitPublicationHead,
Expand Down Expand Up @@ -89,8 +90,10 @@ async function event(
policyEpoch: settings.policyEpoch,
type,
payload,
// The edge is purged after the sites acknowledge; a sensitive-data
// purge is complete only once every destination has.
destinations: process.env.REVALIDATE_WEBHOOK_SECRET
? ["local", "sites"]
? ["local", "sites", ...edgeDestinations()]
: ["local"],
});
}
Expand Down Expand Up @@ -1416,16 +1419,34 @@ export async function applyDocumentationPurge(
return updated;
}
export async function getPurge(teamId: string, purgeId: string) {
return db.query.documentationPurges.findFirst({
const purge = await db.query.documentationPurges.findFirst({
where: and(eq(purges.teamId, teamId), eq(purges.purgeId, purgeId)),
});
if (!purge) return purge;
const delivery = await db.query.documentationDeliveryOutbox.findFirst({
where: and(
eq(outbox.teamId, teamId),
eq(outbox.type, "purge"),
sql`${outbox.payload}->>'purgeId' = ${purgeId}`,
),
});
return { ...purge, deliveredAt: delivery?.completedAt ?? null };
}

export async function deliverDocumentationEvent(
deliver: (
destination: string,
event: typeof outbox.$inferSelect,
) => Promise<void> = async (destination, event) => {
if (destination === "edge" || destination === "edge-repeat") {
const { findTeamById } = await import("../team/queries.js");
const team = await findTeamById(event.teamId);
if (!team) return;
return deliverEdgePurge(destination, event, team.teamId, [
`docs:${team.teamId}`,
`sitemap:${team.teamId}`,
]);
}
if (destination === "sites") {
const { findTeamById } = await import("../team/queries.js");
const { signWebhookPayload } = await import("../services/webhook-signing.js");
Expand Down Expand Up @@ -1512,7 +1533,14 @@ export async function deliverDocumentationEvent(
.update(outbox)
.set({ completedAt: new Date(), leaseToken: null, leaseExpiresAt: null })
.where(and(eq(outbox.eventId, claimed.eventId), eq(outbox.leaseToken, token)));
} catch {
} catch (error) {
if (error instanceof NotYetDue) {
await db
.update(outbox)
.set({ nextAttemptAt: error.at, leaseToken: null, leaseExpiresAt: null })
.where(and(eq(outbox.eventId, claimed.eventId), eq(outbox.leaseToken, token)));
return true;
}
await db
.update(outbox)
.set({
Expand Down
80 changes: 80 additions & 0 deletions apps/api/src/services/edge-purge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { signWebhookPayload } from "./webhook-signing";
import {
deliverEdgePurge,
edgeDestinations,
edgePurgeAdapter,
NotYetDue,
} from "./edge-purge";

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
delete process.env.EDGE_PURGE_URL;
delete process.env.EDGE_PURGE_SECRET;
delete process.env.EDGE_PURGE_ADAPTER;
});

function configure() {
process.env.EDGE_PURGE_URL = "http://purge.test/purge";
process.env.EDGE_PURGE_SECRET = "edge-secret";
}

describe("edge purge", () => {
it("is off until a purge URL and secret are set", () => {
expect(edgePurgeAdapter()).toBeNull();
expect(edgeDestinations()).toEqual([]);
configure();
expect(edgeDestinations()).toEqual(["edge", "edge-repeat"]);
});

it("records purges for a broken configuration and fails their delivery", async () => {
process.env.EDGE_PURGE_ADAPTER = "nope";
expect(edgeDestinations()).toEqual(["edge", "edge-repeat"]);
expect(() => edgePurgeAdapter()).toThrow(/Unknown EDGE_PURGE_ADAPTER/);
await expect(
deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", ["docs:team_a"]),
).rejects.toThrow(/Unknown EDGE_PURGE_ADAPTER/);
});

it("posts the tags signed with the edge secret", async () => {
configure();
const fetchMock = vi.fn(
async (_url: string | URL, _init?: RequestInit) => new Response("ok"),
);
vi.stubGlobal("fetch", fetchMock);
await deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", [
"docs:team_a",
]);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("http://purge.test/purge");
const body = String(init?.body);
expect(JSON.parse(body)).toEqual({ teamId: "team_a", tags: ["docs:team_a"] });
const headers = init?.headers as Record<string, string>;
expect(headers["x-frontlit-signature"]).toBe(
signWebhookPayload("edge-secret", body, Number(headers["x-frontlit-timestamp"])),
);
});

it("fails (so the outbox retries) when the receiver rejects", async () => {
configure();
vi.stubGlobal("fetch", async () => new Response("no", { status: 502 }));
await expect(
deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", ["docs:team_a"]),
).rejects.toThrow(/502/);
});

it("holds the second purge until ten seconds after the event", async () => {
configure();
const fetchMock = vi.fn(async () => new Response("ok"));
vi.stubGlobal("fetch", fetchMock);
const createdAt = new Date();
await expect(
deliverEdgePurge("edge-repeat", { createdAt }, "team_a", ["docs:team_a"]),
).rejects.toBeInstanceOf(NotYetDue);
expect(fetchMock).not.toHaveBeenCalled();
vi.useFakeTimers({ now: createdAt.getTime() + 10_001 });
await deliverEdgePurge("edge-repeat", { createdAt }, "team_a", ["docs:team_a"]);
expect(fetchMock).toHaveBeenCalledOnce();
});
});
85 changes: 85 additions & 0 deletions apps/api/src/services/edge-purge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { signWebhookPayload } from "./webhook-signing";

/**
* Purging an optional edge cache / CDN in front of the public sites
* (docs/site-delivery.md §5). Only used when apps/sites runs with
* `EDGE_CACHE_MODE=purge`. Tags are the same names the sites send in
* `Surrogate-Key` / `Cache-Tag`.
*/
export interface EdgePurgeAdapter {
purge(input: { teamId: string; tags: string[] }): Promise<void>;
}

/** Generic adapter: a signed POST of `{ teamId, tags }` to `EDGE_PURGE_URL`,
* signed with `EDGE_PURGE_SECRET` like every other FrontLit webhook
* (`x-frontlit-timestamp` / `x-frontlit-signature`). Self-hosters connect
* any cache with a small receiver (see deploy/edge-cache). */
export function httpPurgeAdapter(url: string, secret: string): EdgePurgeAdapter {
return {
async purge({ teamId, tags }) {
const body = JSON.stringify({ teamId, tags });
const timestamp = Date.now();
const response = await fetch(url, {
method: "POST",
body,
headers: {
"content-type": "application/json",
"x-frontlit-timestamp": String(timestamp),
"x-frontlit-signature": signWebhookPayload(secret, body, timestamp),
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Edge purge rejected (${response.status})`);
},
};
}

/** 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);
}
throw new Error(`Unknown EDGE_PURGE_ADAPTER "${adapter}"`);
}

/**
* Outbox destinations for an edge purge, delivered after `sites` (Layer 1)
* acknowledges, so the edge can't refill from a stale origin. The second
* purge, ten seconds later, catches pages whose render started before the
* origin was invalidated and was stored at the edge after the first purge.
*/
export const EDGE_DESTINATIONS = ["edge", "edge-repeat"] as const;
export const EDGE_REPEAT_DELAY_MS = 10_000;

/** Edge destinations whenever edge purging is configured at all. A broken
* configuration then fails delivery (retried and logged) rather than
* dropping purges or failing the publish itself. */
export function edgeDestinations(env = process.env): string[] {
return env.EDGE_PURGE_URL || env.EDGE_PURGE_ADAPTER ? [...EDGE_DESTINATIONS] : [];
}

/** Thrown by a destination that isn't due yet; the outbox reschedules it
* without counting a failure. */
export class NotYetDue extends Error {
constructor(readonly at: Date) {
super("Destination not due yet");
}
}

/** Delivers an `edge` / `edge-repeat` destination for one outbox event. */
export async function deliverEdgePurge(
destination: string,
event: { createdAt: Date },
teamId: string,
tags: string[],
): Promise<void> {
if (destination === "edge-repeat") {
const due = event.createdAt.getTime() + EDGE_REPEAT_DELAY_MS;
if (Date.now() < due) throw new NotYetDue(new Date(due));
}
const adapter = edgePurgeAdapter();
if (!adapter) throw new Error("No edge purge adapter configured");
await adapter.purge({ teamId, tags });
}
38 changes: 33 additions & 5 deletions apps/api/src/services/site-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { db } from "../db/client";
import { siteCacheOutbox, teams } from "../db/schema";
import { logger } from "./log";
import { signWebhookPayload } from "./webhook-signing";
import {
deliverEdgePurge,
edgeDestinations,
edgePurgeAdapter,
NotYetDue,
} from "./edge-purge";

/**
* Public-site cache invalidation (docs/site-delivery.md §4.5). apps/sites
Expand All @@ -27,9 +33,10 @@ export const siteTags = {
type Executor = Pick<typeof db, "insert" | "query">;
type TagBuilder = (publicTeamId: string) => string[];

/** Where invalidations go. Unset secret = no public site to invalidate. */
/** Where invalidations go: the sites (Layer 1), then an edge cache when
* one is configured. Unset secret = no public site to invalidate. */
function destinations(): string[] {
return process.env.REVALIDATE_WEBHOOK_SECRET ? ["sites"] : [];
return process.env.REVALIDATE_WEBHOOK_SECRET ? ["sites", ...edgeDestinations()] : [];
}

/**
Expand Down Expand Up @@ -126,14 +133,17 @@ export function revalidateRequest(
type OutboxRow = typeof siteCacheOutbox.$inferSelect;

async function deliver(destination: string, event: OutboxRow): Promise<void> {
if (destination !== "sites") throw new Error(`Unknown destination ${destination}`);
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
if (!secret) throw new Error("REVALIDATE_WEBHOOK_SECRET is not set");
const team = await db.query.teams.findFirst({
where: eq(teams.id, event.teamId),
columns: { teamId: true },
});
if (!team) return;
if (destination === "edge" || destination === "edge-repeat") {
return deliverEdgePurge(destination, event, team.teamId, event.tags);
}
if (destination !== "sites") throw new Error(`Unknown destination ${destination}`);
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
if (!secret) throw new Error("REVALIDATE_WEBHOOK_SECRET is not set");
const { url, init } = revalidateRequest(
event,
team.teamId,
Expand Down Expand Up @@ -200,6 +210,13 @@ export async function deliverSiteCacheEvent(): Promise<boolean> {
.set({ completedAt: new Date(), leaseToken: null, leaseExpiresAt: null })
.where(owned);
} catch (error) {
if (error instanceof NotYetDue) {
await db
.update(siteCacheOutbox)
.set({ nextAttemptAt: error.at, leaseToken: null, leaseExpiresAt: null })
.where(owned);
return true;
}
logger.warn(
{
eventId: claimed.eventId,
Expand Down Expand Up @@ -239,6 +256,17 @@ function scheduleDelivery() {
}

export function startSiteCacheWorker(intervalMs = 1000): () => void {
if (edgeDestinations().length) {
try {
if (!edgePurgeAdapter())
throw new Error("EDGE_PURGE_URL and EDGE_PURGE_SECRET are required");
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Edge purging is misconfigured; edge purges will retry until it is fixed",
);
}
}
let stopped = false;
let lastCleanup = 0;
async function tick() {
Expand Down
8 changes: 8 additions & 0 deletions apps/sites/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,11 @@ DEPLOYMENT_ID=
# Image hosts resized through Next's optimizer, comma separated. Build time.
# Default: cdn.medialit.cloud,images.unsplash.com
IMAGE_REMOTE_HOSTS=

# Optional HTTP cache / CDN in front of this app (docs/site-delivery.md §5):
# none (default) | ttl (edge keeps pages EDGE_CACHE_TTL seconds) | purge
# (edge keeps pages until apps/api purges their tags; set EDGE_PURGE_* there).
EDGE_CACHE_MODE=none
EDGE_CACHE_TTL=60
# Optional separate host for /_next/static. Build time.
ASSET_PREFIX=
3 changes: 2 additions & 1 deletion apps/sites/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ ENV NEXT_TELEMETRY_DISABLED=1
# Read by `next build` (next.config.mjs), not at runtime.
ARG IMAGE_REMOTE_HOSTS
ARG DEPLOYMENT_ID
ENV IMAGE_REMOTE_HOSTS=$IMAGE_REMOTE_HOSTS DEPLOYMENT_ID=$DEPLOYMENT_ID
ARG ASSET_PREFIX
ENV IMAGE_REMOTE_HOSTS=$IMAGE_REMOTE_HOSTS DEPLOYMENT_ID=$DEPLOYMENT_ID ASSET_PREFIX=$ASSET_PREFIX

RUN bun run --filter @frontlit/api-contract build
RUN bun run --filter @frontlit/text-editor build
Expand Down
Loading
Loading