Cache-API en invalidatiestrategieën in Cloudflare Workers
De Cache API in Workers maakt gedetailleerde controle van TTL mogelijk, oud-terwijl-revalideren en ongeldig maken met sleutel: leer bouwen een krachtige, wereldwijd gedistribueerde cachinglaag.
Caching aan de rand: verder dan traditioneel CDN
Een traditioneel CDN slaat statische assets op in de cache volgens standaard HTTP-headers. Cloudflare-werknemers gaat nog veel verder met het concept: met de Cache-API, jouw code TypeScript bepaalt met chirurgische precisie wat er in de cache wordt opgeslagen, hoe lang, hoe het ongeldig wordt gemaakt en welke fallback-logica moet worden toegepast als de cache verouderd is.
Het resultaat is één Programmeerbaar CDN: in plaats van regels te configureren statisch in een dashboard, schrijf bedrijfslogica die direct beslist of een het antwoord is cachebaar, met welke TTL en met welke cachesleutel. Deze flexibiliteit het is vooral waardevol voor REST API's, aangepaste pagina's en semi-dynamische inhoud.
Wat je gaat leren
- Hoe de Cloudflare Workers Cache API werkt en hoe deze verschilt van de Browser Cache API
- Cachingstrategieën: Cache-First, Network-First, Stale-While-Revalidate
- Aangepaste cachesleutels: isoleer de cache op gebruiker, taal en versie
- Ongeldigheid voor URL's, tags en voorvoegsels met de Cloudflare Zone Purge API
- Varieer headers: gedifferentieerde cache voor Accept-Encoding, Accept-Language
- Geavanceerde patronen: cache-opwarming, respijtperiode en stroomonderbreker met KV
- Veel voorkomende fouten en hoe u deze tijdens de productie kunt vermijden
De Cache API: basisprincipes en verschillen met de browser
De Cache-API die wordt weergegeven in Workers volgt dezelfde interface als de Cache-API voor servicemedewerkers browser, maar met belangrijke verschillen. In Workers is de cache gedistribueerd op alle Cloudflare PoP's: Wanneer een medewerker in Frankfurt een antwoord in de cache opslaat, dat antwoord is niet automatisch beschikbaar in Londen of Amsterdam. Elke PoP heeft de eigen lokale 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: belangrijke beperkingen
- Alleen HTTP-verzoeken (geen willekeurige URL's zoals in de browser)
- Het is niet mogelijk om antwoorden te cachen met
Vary: * - De cache is per-PoP: er vindt geen automatische cross-datacenter-invalidatie plaats
cache.delete() - Antwoorden met status 206 (Gedeeltelijke inhoud) kunnen niet in de cache worden opgeslagen
- De maximaal gerespecteerde TTL is 31 dagen
Strategie 1: Cache-First met Fallback Network
De meest gebruikelijke strategie voor API's met zelden veranderende gegevens: serveren vanuit de cache indien beschikbaar, ga anders naar de bron en vul de cache.
// 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: Verouderd, terwijl, opnieuw valideren
De strategie oud-terwijl-revalideren het is degene die het beste geeft compromis tussen dataversheid en waargenomen snelheid: de klant ontvangt Altijd een onmiddellijke reactie van de cache, zelfs als deze op de achtergrond draait de werknemer werkt de cache bij voor het volgende verzoek.
// 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 {}
Aangepaste cachesleutels: Isoleer cache op basis van context
Standaard is de cachesleutel de volledige URL van het verzoek. Maar vaak heb je dat nodig
van caches gescheiden door taal, API-versie, gebruikers- of apparaatlaag. De oplossing is
bouw er een aangepaste cachesleutel als voorwerp Request
met een korte 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 {}
Cacheheaders: s-maxage, oud-terwijl-revalidate, oud-als-error
Cloudflare respecteert de Standaard Cache-Control-headers en verlengt deze de betekenis. Het begrijpen van deze richtlijnen is essentieel:
| Richtlijn | Betekenis | Voorbeeld |
|---|---|---|
max-age=N |
TTL voor browser en CDN (N seconden) | max-age=300 |
s-maxage=N |
TTL alleen voor CDN/proxy (overschrijft de maximale leeftijd voor Cloudflare) | s-maxage=3600, max-age=60 |
stale-while-revalidate=N |
Extra seconden om muf te serveren terwijl het revalideert | s-maxage=60, stale-while-revalidate=300 |
stale-if-error=N |
Seconden waarin moet worden geserveerd als de oorsprong een fout retourneert | stale-if-error=86400 |
no-store |
Cache onder geen enkele omstandigheid | Voor gevoelige gegevens |
private |
Alleen browser in cache, niet CDN | Voor geverifieerde antwoorden |
// 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 {}
Ongeldigheid: opschonen van URL, tag en voorvoegsel
cache.delete(request) verwijder de cache alleen in de lokale PoP waar de Worker draait.
Om de cache ongeldig te maken voor alle PoP's wereldwijd, moet je de API gebruiken
Cloudflare Zone Opschonen REST. Dit is het juiste mechanisme voor contentbeheer
en inzetten.
// 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;
}
Cachetags: semantische ongeldigverklaring
I Cachetags ze zijn het krachtigste mechanisme voor invalidatie
selectief. Ze werken door een header toe te voegen Cache-Tag naar de antwoorden:
elk antwoord kan meerdere tags hebben en u kunt alle URL's ongeldig maken
gekoppeld aan een tag met een enkele API-aanroep.
// 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 {}
Geavanceerd patroon: cache met KV als L2
De Cache API heeft een belangrijke beperking: deze is niet programmatisch toegankelijk voor willekeurig lezen/schrijven, alleen HTTP-verzoeken. Voor meer patronen complex (zoals gecoördineerde invalidatie, stroomonderbreker of objectcache niet-HTTP), gebruik Werknemers KV als 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;
}
Cache-opwarming: vul de cache vooraf in
Il cache-opwarming het is de gewoonte om eerst de cache vooraf in te vullen dat er echte verzoeken binnenkomen, waardoor het probleem met de koude cache na de implementatie wordt geëlimineerd enorme ontkenningen. Het wordt geïmplementeerd met een Worker die is gepland via 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;
}
Cache-foutopsporing en -bewaking
Cloudflare maakt diagnostische headers zichtbaar in reacties om de cachestatus te begrijpen.
Het belangrijkste is CF-Cache-Status:
| CF-cache-status | Betekenis |
|---|---|
HIT |
Gediend vanuit Cloudflare-cache |
MISS |
Niet in de cache opgeslagen, opgevraagd bij de oorsprong |
EXPIRED |
In cache opgeslagen maar TTL verlopen, verzoek aan oorsprong |
STALE |
Oud geserveerd (oud-terwijl-revalideren) |
BYPASS |
Cache omzeild (cookie, auth-header, etc.) |
DYNAMIC |
Niet-cachebaar (dynamische respons) |
REVALIDATED |
Cache gevalideerd met oorsprong (304 niet gewijzigd) |
// 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;
}
Conclusies en volgende stappen
De Cloudflare Workers Cache API transformeert het CDN van een passieve tool naar een component actief in de architectuur. Met de strategieën die in dit artikel worden beschreven, kun je bouwen een granulaire, semantische, ongeldige cachinglaag die de belasting vermindert 70-90% op oorsprong voor typische openbare API-workloads.
De belangrijkste punten om te onthouden: gebruik ctx.waitUntil() om de reactie niet te blokkeren
voor de client, bouw aangepaste cachesleutels om verschillende contexten te isoleren, gebruik cachetags
voor semantische invalidatie, en combineer Cache API met KV voor complexere patronen.
Volgende artikelen in de serie
- Artikel 9: Testen van werknemers op lokaal niveau — Miniflare, Vitest e Wrangler Dev: unit-tests en integratietests schrijven voor werknemers zonder implementatie.
- Artikel 10: Full-stack architecturen aan de edge — Casestudy door Zero to Production: een complete REST API met Workers + D1 + R2 en CI/CD op GitHub-acties.







