Raveo: jak jsem postavil vlastní Cloudflare stack pro weby na zakázku.
Chtěl jsem mít základ, ze kterého budu stavět víc projektů. Žádný klasický server, správa obsahu pro klienta, globální dosah. Tady je jak to celé funguje.
Problém s tvorbou webů na zakázku je, že pokaždé začínáš znovu. Vybereš CMS, nastavíš hosting, vymyslíš jak dostat obsah na frontend, vyřešíš cache. A příště to samé znovu, jen trochu jinak.
Chtěl jsem mít vlastní základnu: monorepo s CMS i frontendem, nasaditelné jedním příkazem, s obsahem, který si klient spravuje sám. Bez klasického serveru. Tak vznikl Raveo.
Co je Raveo
Raveo je monorepo se dvěma Cloudflare Workers. Jeden běží PayloadCMS na Next.js jako headless CMS. Druhý běží Astro frontend. Všechno ostatní jsou Cloudflare služby: D1 jako databáze, R2 na media, KV na cache.
raveo/
├── apps/
│ ├── cms/ PayloadCMS + Next.js → Cloudflare Worker
│ └── web/ Astro v6 → Cloudflare Worker
└── packages/
├── types/ vygenerované typy z PayloadCMS schématu
├── ui/ sdílené komponenty + Lexical renderer
└── config/ sdílené tsconfig + Biome konfigurace
Monorepo spravuje Turborepo s pnpm workspace. Deploy obou workerů najednou jde přes jeden příkaz.
Cloudflare primitivy: co to všechno je
Než jdu do implementace, rychle co každá z těch služeb dělá, protože to není samozřejmé.
Workers jsou V8 isolates — malé izolované prostředí, které běží kód při každém requestu. Nespustí se jako tradiční server, který čeká v paměti. Spustí se na každý požadavek, vyřídí ho a zaniknou. Žádný cold start, globální síť, platíš za skutečné requesty ne za provoz serveru.
D1 je serverless SQLite přímo na Cloudflare hraně. Žádný PostgreSQL server, žádná managed databáze. SQLite soubor replikovaný globálně, přístupný přes Workers binding. Pro CMS databázi, která nepotřebuje miliony concurrent writes za sekundu, je to ideální.
R2 je object storage kompatibilní s S3 API. Media soubory, obrázky, dokumenty. Bez egress poplatků za stahování — což je na rozdíl od klasického S3 výrazný rozdíl při provozu.
KV je distribuovaný key-value store s velmi nízkou latencí čtení. Hodí se na cache: jednou zapíšeš, mnohokrát čteš. S TTL expirací.
PayloadCMS na Workers: D1 a R2 adaptéry
PayloadCMS normálně předpokládá Node.js server a PostgreSQL nebo MongoDB. Cloudflare Workers jsou runtime bez Node.js a bez přístupu k externím databázím přes TCP. Aby to fungovalo, existují adaptéry.
V payload.config.ts vypadá konfigurace takhle:
export default buildConfig({
collections: [Users, Media, Categories, Posts, Pages, Forms, FormSubmissions],
globals: [Navigation, SiteSettings],
editor: lexicalEditor(),
// D1 místo PostgreSQL
db: sqliteD1Adapter({
binding: cloudflare.env.D1,
push: !isSeed,
}),
// R2 místo lokálního filesystému nebo S3
plugins: [
r2Storage({
bucket: cloudflare.env.R2,
collections: { media: true },
}),
],
});
D1 binding je přímý přístup k SQLite databázi bez sítě. R2 binding je přímý přístup k object storage. Vše jde přes interní Cloudflare kanály, ne přes veřejný internet.
Výsledek: PayloadCMS admin rozhraní běží jako Worker. Klient se přihlásí, spravuje obsah, ukládá změny. Databáze je D1, media jdou do R2.
Service Bindings: jak spolu workery mluví
Dva Workers potřebují spolu komunikovat. Naivní řešení je volat CMS přes HTTP, ale to přidává latenci, DNS lookup a další síťový hop.
Cloudflare má na tohle Service Bindings. Přímé propojení mezi Workers bez HTTP, bez DNS, s nulovou latencí. Worker A zavolá Worker B jako funkci, ne jako HTTP endpoint.
V middlewaru to vypadá takhle:
async function fetchFromCMS(fetcher: Fetcher | null, cmsUrl: string, path: string) {
const url = `https://cms${path}`;
const fallbackUrl = `${cmsUrl}${path}`;
const res = fetcher
? await fetcher.fetch(url) // produkce: service binding, nulová latence
: await fetch(fallbackUrl); // dev: HTTP na localhost:3000
if (!res.ok) return null;
return await res.json();
}
fetcher je Service Binding — v produkci je k dispozici přes env.CMS. V lokálním vývoji není, takže padne zpátky na HTTP. Přepínání je automatické.
Middleware a KV cache
Astro middleware se spustí na každý request. Jeho úkolem je načíst data z CMS a předat je stránkám přes locals. Ale volat CMS na každý request by bylo zbytečně pomalé.
Proto je mezi tím KV cache:
async function cachedFetch(fetcher, cmsUrl, path, cache) {
// 1. Zkus KV cache
if (cache) {
const cached = await cache.get(path, 'json');
if (cached) return cached;
}
// 2. Cache miss — zavolej CMS
const data = await fetchFromCMS(fetcher, cmsUrl, path);
// 3. Ulož do KV na 5 minut (fire-and-forget, nezpomalí odpověď)
if (data && cache) {
cache.put(path, JSON.stringify(data), { expirationTtl: 300 }).catch(() => {});
}
return data;
}
Klíčový detail je fire-and-forget zápis do KV. Odpověď uživateli nečeká na to, až se data zapíšou do cache. Zápis jde na pozadí.
Na každý request se paralelně načtou čtyři věci:
const [navigation, siteSettings, pagesData, postsData] = await Promise.all([
cachedFetch(fetcher, cmsUrl, '/api/globals/navigation?depth=1', cache),
cachedFetch(fetcher, cmsUrl, '/api/globals/site-settings?depth=1', cache),
cachedFetch(fetcher, cmsUrl, '/api/pages?depth=2&limit=100&where[status][equals]=published', cache),
cachedFetch(fetcher, cmsUrl, '/api/posts?depth=2&limit=100&where[status][equals]=published', cache),
]);
Promise.all je důležitý — všechna čtyři volání jdou paralelně, ne za sebou.
Revalidace obsahu
KV cache má TTL pět minut. Ale co když klient uloží změnu a chce ji vidět okamžitě?
Každá kolekce v CMS má afterChange hook. Když se obsah uloží, hook zavolá Web Worker a řekne mu ať zneplatní cache:
const revalidate = async () => {
if (process.env.NODE_ENV === 'production') {
// Service binding: CMS Worker volá Web Worker přímo
await cfEnv.WEB.fetch(
new Request('https://web/api/revalidate', {
method: 'POST',
headers: { 'x-revalidate-secret': cfEnv.REVALIDATE_SECRET ?? '' },
}),
);
} else {
await fetch(`${webUrl}/api/revalidate`, { method: 'POST', ... });
}
};
export const revalidateAfterChange: CollectionAfterChangeHook = async ({ doc }) => {
await revalidate();
return doc;
};
Po revalidaci KV cache vyprší a při dalším requestu se načtou čerstvá data z CMS. Žádný rebuild pipeline, žádné čekání na deploy.
Lexical renderer: JSON do HTML bez JavaScriptu
PayloadCMS ukládá obsah bohatého textu ve formátu Lexical JSON — stromová struktura uzlů. Hotové knihovny to řeší přes React a klientský JavaScript. Jenže Astro stránky jsou statické, klientský JavaScript nechci. Napsal jsem vlastní server-side renderer.
Zajímavá část je formátování textu. Lexical ukládá formát jako bitový příznak — číslo, kde každý bit znamená jiný styl:
function renderTextFormat(text: string, format: number): string {
let result = escapeHtml(text);
if (format & 16) result = `<code>${result}</code>`; // inline kód
if (format & 1) result = `<strong>${result}</strong>`; // tučné
if (format & 2) result = `<em>${result}</em>`; // kurzíva
if (format & 8) result = `<u>${result}</u>`; // podtržení
if (format & 4) result = `<s>${result}</s>`; // přeškrtnutí
if (format & 32) result = `<sub>${result}</sub>`; // dolní index
if (format & 64) result = `<sup>${result}</sup>`; // horní index
}
Bitový AND (&) zkontroluje jestli je daný bit nastavený. Text může být tučný a zároveň kurzíva — oba bity jsou nastavené zároveň. Renderer zpracovává celý strom rekurzivně: odstavce, nadpisy, citace, seznamy, checkboxy, odkazy, obrázky. Výstup je čisté HTML bez klientského JavaScriptu.
Rate limiting na KV
Rate limiter jsem nechtěl řešit přes externí službu. KV namespace pro cache je k dispozici, tak jsem ho použil i na rate limiting.
Implementace je sliding window: pro každou IP adresu uchovává počet requestů a začátek časového okna:
interface RateLimitEntry {
count: number;
windowStart: number;
}
Klíčový design rozhodnutí: rate limiter fails open — pokud KV není dostupný, request projde. Alternativa by byla odmítnout request, ale výpadek KV by pak vyřadil celý web. Fail open je v tomhle případě správná volba.
POST requesty jsou omezeny na 10 za minutu, API endpointy na 30. Odpověď vrací RFC standardní hlavičky RateLimit-Remaining a Retry-After.
Kde to teď je a kam míří
Připravuju na Raveou web pro mé Skautské středisko Prácheň. Bude to první reálné nasazení stacku v produkci.
Do budoucna mě zajímá integrace s Medusa.js jako transakčním backendem. Z Ravea by tak šlo stavět e-shopová řešení na zakázku: PayloadCMS na správu obsahu, Medusa na produkty a objednávky, Astro na frontend. Celé bez vlastního serveru, globálně distribuované, s obsahem, který si klient spravuje sám.
Celý projekt je open source na github.com/raveo-dev/raveo.