Cache API a strategie zneplatnění v Cloudflare Workers
Cache API v Workers umožňuje granulární kontrolu TTL, stale-while-revalidate a zneplatnění podle klíče: zjistěte, jak sestavit vysoce výkonná globální distribuovaná vrstva mezipaměti.
Caching at the Edge: Beyond Traditional CDN
Tradiční CDN ukládá statická aktiva do mezipaměti podle standardních HTTP hlaviček. Pracovníci Cloudflare posouvá koncept mnohem dále: s Cache API, váš kód TypeScript řídí s chirurgickou přesností, co se ukládá do mezipaměti, jak dlouho, jak je zneplatněna a jakou záložní logiku použít, když je mezipaměť zastaralá.
Výsledek je jeden Programovatelné CDN: místo konfigurace pravidel staticky na řídicím panelu, napište obchodní logiku, která za běhu rozhodne, zda a odpověď je cacheable, s jakým TTL a s kterým klíčem cache. Tato flexibilita je zvláště cenná pro REST API, vlastní stránky a polodynamický obsah.
Co se naučíte
- Jak funguje Cloudflare Workers Cache API a jak se liší od Browser Cache API
- Strategie ukládání do mezipaměti: Cache-First, Network-First, Stale-While-Revalidate
- Vlastní klíče mezipaměti: izolujte mezipaměť podle uživatele, jazyka, verze
- Zrušení platnosti pro adresy URL, značky a předpony pomocí rozhraní Cloudflare Zone Purge API
- Různé hlavičky: diferencovaná mezipaměť pro Accept-Encoding, Accept-Language
- Pokročilé vzory: zahřívání mezipaměti, doba odkladu a jistič s KV
- Časté chyby a jak se jim ve výrobě vyhnout
Cache API: Základy a rozdíly oproti prohlížeči
Rozhraní API mezipaměti vystavené v Workers se řídí stejným rozhraním jako rozhraní Service Worker Cache API prohlížeč, ale s důležitými rozdíly. V Workers je mezipaměť distribuováno na všech Cloudflare PoP: Když pracovník ve Frankfurtu uloží odpověď do mezipaměti, tato odpověď není automaticky dostupná v Londýně nebo Amsterdamu. Každý PoP má vlastní místní cache.
// Accesso alla cache nel Worker
// In Workers esiste un unico "default" cache namespace
const cache = caches.default;
// Oppure cache named (isolata per nome, utile per namespace logici)
const apiCache = await caches.open('api-v2');
// Le operazioni fondamentali:
// cache.match(request) -> Response | undefined
// cache.put(request, response) -> void
// cache.delete(request) -> boolean
Cache API: Důležitá omezení
- Pouze požadavky HTTP (nikoli libovolné adresy URL jako v prohlížeči)
- Není možné ukládat odpovědi do mezipaměti pomocí
Vary: * - Mezipaměť je pro PoP: nedochází k automatickému zneplatnění mezi datovými centry
cache.delete() - Odpovědi se stavem 206 (částečný obsah) nelze uložit do mezipaměti
- Maximální dodržovaná TTL je 31 dní
Strategie 1: Cache-First s záložní sítí
Nejběžnější strategie pro rozhraní API se zřídka se měnícími daty: poskytování z mezipaměti pokud je k dispozici, jinak přejděte ke zdroji a naplňte mezipaměť.
// worker.ts - Cache-First Strategy
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Solo richieste GET sono cacheable
if (request.method !== 'GET') {
return fetch(request);
}
const cache = caches.default;
// 1. Controlla la cache
let response = await cache.match(request);
if (response) {
// Cache HIT: aggiungi header diagnostico e restituisci
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', 'HIT');
return new Response(response.body, {
status: response.status,
headers,
});
}
// 2. Cache MISS: fetch dall'origin
response = await fetch(request);
// 3. Clona la risposta (il body e un ReadableStream, consumabile una sola volta)
const responseToCache = response.clone();
// 4. Metti in cache con ctx.waitUntil() per non bloccare la risposta al client
ctx.waitUntil(
cache.put(request, responseToCache)
);
// 5. Restituisci la risposta originale con header diagnostico
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', 'MISS');
return new Response(response.body, {
status: response.status,
headers,
});
},
};
interface Env {}
Strategie 2: Stale-While-Revalidate
Strategie stale-while-revalidate je to ten, který dává to nejlepší kompromis mezi čerstvostí dat a vnímanou rychlostí: klient obdrží Vždy okamžitá odpověď z mezipaměti, i když běží na pozadí Worker aktualizuje mezipaměť pro další požadavek.
// Stale-While-Revalidate implementato manualmente
// (Cloudflare supporta anche il header standard, ma questa versione offre più controllo)
const CACHE_TTL = 60; // Secondi prima che la cache sia "fresh"
const STALE_TTL = 300; // Secondi aggiuntivi in cui la cache e "stale but usable"
interface CacheMetadata {
cachedAt: number;
ttl: number;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (request.method !== 'GET') return fetch(request);
const cache = caches.default;
// Crea una Request con una custom cache key che include i metadata
const cacheKey = new Request(request.url, {
headers: request.headers,
});
const cached = await cache.match(cacheKey);
if (cached) {
const cachedAt = parseInt(cached.headers.get('X-Cached-At') ?? '0');
const age = (Date.now() - cachedAt) / 1000;
if (age < CACHE_TTL) {
// FRESH: servi dalla cache senza revalidazione
return addCacheHeaders(cached, 'FRESH', age);
}
if (age < CACHE_TTL + STALE_TTL) {
// STALE: servi dalla cache ma revalida in background
ctx.waitUntil(revalidate(cacheKey, cache));
return addCacheHeaders(cached, 'STALE', age);
}
}
// MISS o troppo vecchio: fetch sincrono
return fetchAndCache(cacheKey, cache, ctx);
},
};
async function revalidate(cacheKey: Request, cache: Cache): Promise<void> {
const fresh = await fetch(cacheKey.url);
if (fresh.ok) {
const toCache = addTimestamp(fresh);
await cache.put(cacheKey, toCache);
}
}
async function fetchAndCache(
cacheKey: Request,
cache: Cache,
ctx: ExecutionContext
): Promise<Response> {
const response = await fetch(cacheKey.url);
if (response.ok) {
const toCache = addTimestamp(response.clone());
ctx.waitUntil(cache.put(cacheKey, toCache));
}
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', 'MISS');
return new Response(response.body, { status: response.status, headers });
}
function addTimestamp(response: Response): Response {
const headers = new Headers(response.headers);
headers.set('X-Cached-At', String(Date.now()));
// Cache-Control: max-age elevato per far "sopravvivere" la risposta in cache
headers.set('Cache-Control', 'public, max-age=86400');
return new Response(response.body, { status: response.status, headers });
}
function addCacheHeaders(response: Response, status: string, age: number): Response {
const headers = new Headers(response.headers);
headers.set('X-Cache-Status', status);
headers.set('Age', String(Math.floor(age)));
return new Response(response.body, { status: response.status, headers });
}
interface Env {}
Vlastní klíče mezipaměti: Izolujte mezipaměť podle kontextu
Ve výchozím nastavení je klíčem mezipaměti úplná adresa URL požadavku. Ale často potřebujete
mezipamětí oddělených podle jazyka, verze API, uživatele nebo úrovně zařízení. Řešením je
postavit jednu vlastní klíč mezipaměti jako předmět Request
s krátkou URL.
// Cache differenziata per lingua e versione API
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default;
const url = new URL(request.url);
// Estrai parametri rilevanti per la cache key
const lang = request.headers.get('Accept-Language')?.split(',')[0]?.split('-')[0] ?? 'en';
const apiVersion = url.searchParams.get('v') ?? 'v1';
const tier = request.headers.get('X-User-Tier') ?? 'free';
// Costruisci una URL sintetica come cache key
// Non deve essere una URL reale, solo identificativa
const cacheKeyUrl = new URL(request.url);
cacheKeyUrl.searchParams.set('_ck_lang', lang);
cacheKeyUrl.searchParams.set('_ck_v', apiVersion);
// Non includiamo 'tier' nella key se vuoi condividere la cache tra tier
const cacheKey = new Request(cacheKeyUrl.toString(), {
method: 'GET',
// Importante: non copiare headers di autenticazione nella cache key
// altrimenti ogni utente avrebbe la propria cache entry
});
// Cerca nella cache con la custom key
let response = await cache.match(cacheKey);
if (response) {
return response;
}
// Fetch dall'origin passando la richiesta originale (con auth headers)
const originResponse = await fetch(request);
if (originResponse.ok && isCacheable(originResponse)) {
const toCache = setCacheHeaders(originResponse.clone(), 300);
ctx.waitUntil(cache.put(cacheKey, toCache));
}
return originResponse;
},
};
function isCacheable(response: Response): boolean {
// Non mettere in cache risposte con dati personali o Set-Cookie
if (response.headers.has('Set-Cookie')) return false;
if (response.headers.get('Cache-Control')?.includes('private')) return false;
if (response.headers.get('Cache-Control')?.includes('no-store')) return false;
return true;
}
function setCacheHeaders(response: Response, maxAge: number): Response {
const headers = new Headers(response.headers);
headers.set('Cache-Control', `public, max-age=${maxAge}, s-maxage=${maxAge}`);
// Rimuovi header che potrebbero impedire il caching
headers.delete('Set-Cookie');
return new Response(response.body, { status: response.status, headers });
}
interface Env {}
Záhlaví mezipaměti: s-maxage, stale-while-revalidate, stale-if-error
Cloudflare respektuje Standardní hlavičky Cache-Control a prodlužuje ji význam. Pochopení těchto pokynů je nezbytné:
| Směrnice | Význam | Příklad |
|---|---|---|
max-age=N |
TTL pro prohlížeč a CDN (N sekund) | max-age=300 |
s-maxage=N |
TTL pouze pro CDN/proxy (přepíše maximální věk pro Cloudflare) | s-maxage=3600, max-age=60 |
stale-while-revalidate=N |
Další sekundy, během kterých se má služba zobrazovat, jsou zastaralé, zatímco se znovu ověřuje | s-maxage=60, stale-while-revalidate=300 |
stale-if-error=N |
Sekundy, za které se má obsloužit zastaralé, pokud zdroj vrátí chybu | stale-if-error=86400 |
no-store |
V žádném případě neukládejte do mezipaměti | Pro citlivá data |
private |
Pouze prohlížeč mezipaměti, nikoli CDN | Pro ověřené odpovědi |
// Esempio: API con s-maxage e stale-while-revalidate via header
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Routing con TTL differenziati per tipo di risorsa
if (url.pathname.startsWith('/api/products')) {
return fetchWithCacheHeaders(request, {
sMaxAge: 300, // 5 minuti fresh in CDN
staleWhileRevalidate: 3600, // 1 ora stale accettabile
staleIfError: 86400, // 1 giorno stale in caso di errore origin
});
}
if (url.pathname.startsWith('/api/user')) {
// Dati utente: non cacheare in CDN
return fetchWithCacheHeaders(request, {
sMaxAge: 0,
private: true,
});
}
if (url.pathname.startsWith('/static')) {
// Asset statici: cache aggressiva
return fetchWithCacheHeaders(request, {
sMaxAge: 31536000, // 1 anno
immutable: true,
});
}
return fetch(request);
},
};
interface CacheOptions {
sMaxAge?: number;
staleWhileRevalidate?: number;
staleIfError?: number;
private?: boolean;
immutable?: boolean;
}
async function fetchWithCacheHeaders(
request: Request,
options: CacheOptions
): Promise<Response> {
const response = await fetch(request);
const headers = new Headers(response.headers);
let cacheControl = '';
if (options.private) {
cacheControl = 'private, no-store';
} else {
const parts: string[] = ['public'];
if (options.sMaxAge !== undefined) parts.push(`s-maxage=${options.sMaxAge}`);
if (options.staleWhileRevalidate) parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
if (options.staleIfError) parts.push(`stale-if-error=${options.staleIfError}`);
if (options.immutable) parts.push('immutable');
cacheControl = parts.join(', ');
}
headers.set('Cache-Control', cacheControl);
return new Response(response.body, { status: response.status, headers });
}
interface Env {}
Zrušení platnosti: Vymazání adresy URL, značky a předpony
cache.delete(request) vymažte mezipaměť pouze v místním PoP, kde běží Worker.
Chcete-li zneplatnit mezipaměť napříč všemi PoP po celém světě, musíte použít API
Cloudflare Zone Purge REST. Toto je správný mechanismus pro správu obsahu
a nasadit.
// Invalidation globale tramite Cloudflare API
// Da usare tipicamente da un webhook CMS o da un Worker admin
interface PurgeOptions {
files?: string[]; // URL specifici
tags?: string[]; // Cache-Tag headers
prefixes?: string[]; // URL prefix
hosts?: string[]; // Tutti gli URL di un host
}
async function purgeCloudflareCache(
zoneId: string,
apiToken: string,
options: PurgeOptions
): Promise<void> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(options),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Purge failed: ${JSON.stringify(error)}`);
}
}
// Worker che funge da webhook per invalidazione CMS
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
// Verifica il secret del webhook
const secret = request.headers.get('X-Webhook-Secret');
if (secret !== env.WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const body = await request.json() as WebhookPayload;
// Invalida le URL specifiche aggiornate dal CMS
if (body.type === 'post.updated') {
await purgeCloudflareCache(env.ZONE_ID, env.CF_API_TOKEN, {
files: [
`https://example.com/blog/${body.slug}`,
`https://example.com/api/posts/${body.id}`,
`https://example.com/sitemap.xml`,
],
});
}
// Invalida per tag (richiede Cache-Tag header sulle risposte origin)
if (body.type === 'category.updated') {
await purgeCloudflareCache(env.ZONE_ID, env.CF_API_TOKEN, {
tags: [`category-${body.categorySlug}`],
});
}
return new Response(JSON.stringify({ purged: true }), {
headers: { 'Content-Type': 'application/json' },
});
},
};
interface WebhookPayload {
type: string;
id?: string;
slug?: string;
categorySlug?: string;
}
interface Env {
ZONE_ID: string;
CF_API_TOKEN: string;
WEBHOOK_SECRET: string;
}
Značky mezipaměti: Sémantické zneplatnění
I Značky mezipaměti jsou nejúčinnějším mechanismem pro zneplatnění
selektivní. Fungují přidáním záhlaví Cache-Tag na odpovědi:
každá odpověď může mít více značek a můžete zrušit platnost všech adres URL
spojené se značkou s jedním voláním API.
// Origin server o Worker che aggiunge Cache-Tag alle risposte
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const response = await fetch(request);
const headers = new Headers(response.headers);
// Aggiungi tag semantici basati sul contenuto
const tags: string[] = [];
// Tag per tipo di risorsa
if (url.pathname.startsWith('/api/products')) {
const productId = url.pathname.split('/')[3];
tags.push('products'); // Invalida tutti i prodotti
if (productId) tags.push(`product-${productId}`); // Invalida questo prodotto specifico
}
if (url.pathname.startsWith('/api/categories')) {
const catId = url.pathname.split('/')[3];
tags.push('categories');
if (catId) tags.push(`category-${catId}`);
}
// Tag per versione dell'API
const apiVersion = url.pathname.split('/')[2];
if (apiVersion?.startsWith('v')) {
tags.push(`api-${apiVersion}`);
}
if (tags.length > 0) {
// Cache-Tag: lista separata da virgole, max 16KB
headers.set('Cache-Tag', tags.join(','));
}
return new Response(response.body, { status: response.status, headers });
},
};
// Esempio di invalidazione per tag dopo un aggiornamento:
// POST /api/admin/purge
// { "tags": ["product-123", "categories"] }
// Invalida tutte le URL che hanno Cache-Tag: product-123 o categories
interface Env {}
Pokročilý vzor: Mezipaměť s KV jako L2
Cache API má důležité omezení: není programově přístupné pro libovolné čtení/zápis pouze požadavky HTTP. Pro více vzorů komplexní (jako je koordinované zneplatnění, jistič nebo mezipaměť objektů non-HTTP), použití Workers KV jako L2 cache.
// Cache a due livelli: Cache API (L1, HTTP) + KV (L2, programmabile)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const cacheKey = buildCacheKey(url);
// L1: Cache API (piu veloce, locale al PoP)
const l1Cache = caches.default;
const l1Hit = await l1Cache.match(request);
if (l1Hit) {
return addHeader(l1Hit, 'X-Cache', 'L1-HIT');
}
// L2: KV Store (globale, programmabile, con TTL gestito da KV)
const kvCached = await env.API_CACHE.getWithMetadata<CacheMetadata>(cacheKey);
if (kvCached.value !== null) {
const { value, metadata } = kvCached;
// Ricostruisci una Response dalla stringa KV
const cachedResponse = new Response(value, {
headers: {
'Content-Type': metadata?.contentType ?? 'application/json',
'Cache-Control': 'public, max-age=60',
'X-Cache': 'L2-HIT',
'X-Cached-At': String(metadata?.cachedAt ?? 0),
},
});
// Popola anche L1 per richieste successive nello stesso PoP
ctx.waitUntil(l1Cache.put(request, cachedResponse.clone()));
return cachedResponse;
}
// MISS su entrambi i livelli: fetch dall'origin
const originResponse = await fetch(request);
if (originResponse.ok) {
const body = await originResponse.text();
const contentType = originResponse.headers.get('Content-Type') ?? 'application/json';
const metadata: CacheMetadata = {
cachedAt: Date.now(),
contentType,
url: url.toString(),
};
// Salva in KV con TTL di 5 minuti
ctx.waitUntil(
env.API_CACHE.put(cacheKey, body, {
expirationTtl: 300,
metadata,
})
);
// Salva anche in L1
const toL1 = new Response(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=60',
'X-Cache': 'MISS',
},
});
ctx.waitUntil(l1Cache.put(request, toL1));
return new Response(body, {
headers: {
'Content-Type': contentType,
'X-Cache': 'MISS',
},
});
}
return addHeader(originResponse, 'X-Cache', 'BYPASS-ERROR');
},
};
function buildCacheKey(url: URL): string {
// Normalizza l'URL per la cache key (rimuovi query params non semantici)
const params = new URLSearchParams(url.searchParams);
params.delete('utm_source');
params.delete('utm_medium');
params.delete('_t'); // timestamp di cache-busting
params.sort(); // ordine deterministico
return `${url.pathname}?${params.toString()}`;
}
function addHeader(response: Response, key: string, value: string): Response {
const headers = new Headers(response.headers);
headers.set(key, value);
return new Response(response.body, { status: response.status, headers });
}
interface CacheMetadata {
cachedAt: number;
contentType: string;
url: string;
}
interface Env {
API_CACHE: KVNamespace;
}
Zahřívání mezipaměti: Předvyplnění mezipaměti
Il zahřívání mezipaměti jedná se o praxi předběžného naplnění mezipaměti jako prvního že přijdou skutečné požadavky, čímž se po nasazení eliminuje problém se studenou mezipamětí masivní invalidizace. Je implementován pomocí Worker naplánovaného přes Cron Trigger.
// wrangler.toml - Cron Trigger per cache warming
// [triggers]
// crons = ["*/15 * * * *"] # Ogni 15 minuti
// worker.ts - Cache Warming Worker
const URLS_TO_WARM = [
'https://api.example.com/products?featured=true',
'https://api.example.com/categories',
'https://api.example.com/homepage',
'https://api.example.com/navigation',
];
export default {
// Scheduled handler per Cron Trigger
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
console.log(`Cache warming started at ${new Date(event.scheduledTime).toISOString()}`);
const results = await Promise.allSettled(
URLS_TO_WARM.map(url => warmUrl(url))
);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
console.log(`Cache warming complete: ${succeeded} success, ${failed} failed`);
},
async fetch(request: Request): Promise<Response> {
return new Response('Cache Warmer Worker', { status: 200 });
},
};
async function warmUrl(url: string): Promise<void> {
// Forza bypass della cache aggiungendo header speciale
// (da gestire lato Worker principale con whitelist IP o secret)
const response = await fetch(url, {
headers: {
'Cache-Control': 'no-cache', // Forza revalidazione
'X-Cache-Warm': 'true',
},
});
if (!response.ok) {
throw new Error(`Failed to warm ${url}: ${response.status}`);
}
}
interface Env {}
interface ScheduledEvent {
scheduledTime: number;
cron: string;
}
Ladění a monitorování mezipaměti
Cloudflare odhaluje diagnostická záhlaví v odpovědích, aby pochopila stav mezipaměti.
Nejdůležitější je CF-Cache-Status:
| CF-Cache-Status | Význam |
|---|---|
HIT |
Obsluhováno z mezipaměti Cloudflare |
MISS |
Neuloženo do mezipaměti, požadováno u zdroje |
EXPIRED |
Uloženo do mezipaměti, ale vypršela platnost TTL, požadavek na původ |
STALE |
Obsluhováno zastaralé (zastaralé-během-obnovení platnosti) |
BYPASS |
Mezipaměť byla vynechána (cookie, hlavička ověření atd.) |
DYNAMIC |
Nelze uložit do mezipaměti (dynamická odezva) |
REVALIDATED |
Mezipaměť ověřena s původem (304 neupraveno) |
// Worker che logga le metriche di cache su KV Analytics
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const response = await fetch(request);
const cacheStatus = response.headers.get('CF-Cache-Status') ?? 'UNKNOWN';
// Logga la metrica in background
ctx.waitUntil(
logCacheMetric(env, {
url: request.url,
status: cacheStatus,
timestamp: Date.now(),
country: request.cf?.country ?? 'unknown',
datacenter: request.cf?.colo ?? 'unknown',
})
);
return response;
},
};
async function logCacheMetric(env: Env, metric: CacheMetric): Promise<void> {
// Aggrega per finestre di 1 minuto
const minuteKey = `metrics:${Math.floor(metric.timestamp / 60000)}:${metric.status}`;
const current = parseInt(await env.METRICS_KV.get(minuteKey) ?? '0');
await env.METRICS_KV.put(minuteKey, String(current + 1), { expirationTtl: 3600 });
}
interface CacheMetric {
url: string;
status: string;
timestamp: number;
country: string;
datacenter: string;
}
interface Env {
METRICS_KV: KVNamespace;
}
Závěry a další kroky
Cloudflare Workers Cache API transformuje CDN z pasivního nástroje na komponentu aktivní v architektuře. Pomocí strategií popsaných v tomto článku můžete stavět granulární, sémantická, neplatná mezipaměťová vrstva, která snižuje zatížení 70–90 % na původu pro typické veřejné úlohy API.
Klíčové body k zapamatování: použití ctx.waitUntil() aby neblokoval odpověď
klientovi, vytvořte vlastní klíče mezipaměti pro izolaci různých kontextů, použijte značky mezipaměti
pro sémantické zneplatnění a pro složitější vzory zkombinujte rozhraní Cache API s KV.
Další články v seriálu
- Článek 9: Testování pracovníků v místním prostředí — Miniflare, Vitest e Wrangler Dev: jak psát unit testy a integrační testy pro Workers bez nasazení.
- Článek 10: Full-Stack Architectures at the Edge — Případová studie od Zero to Production: Kompletní REST API s Workers + D1 + R2 a CI/CD na GitHub Actions.







