Mettre en cache les réponses d’une API publique

CloudflareBeginner
Pratiquer maintenant

Introduction

Un catalogue public d’assistance reçoit plusieurs fois les mêmes demandes pour une langue et une catégorie données. Réutiliser les réponses peut réduire le travail répété, mais un cache ne doit jamais mélanger des données propres à différents clients ni transformer une erreur en contenu public mis en cache. Vous observerez d’abord la génération sans cache, puis vous ajouterez une politique de cache explicite, testerez l’expiration et l’invalidation ciblée, avant de déployer l’application et d’en vérifier les limites.

Cet atelier autonome commence dans /home/labex/project/public-cache avec Node.js 22.22.0, Wrangler 4.131.1 installé localement dans le projet, Miniflare 4.20260730.0 pour une évaluation isolée et un jeu de réponses synthétiques. Utilisez votre propre compte d’apprentissage ainsi que les procédures d’autorisation, de déploiement et de gestion des secrets déjà enseignées. Aucune ancienne VM, aucune ressource, aucun domaine acheté, aucune base de données ni aucune mise à niveau payante ne sont nécessaires. Les requêtes sont comptabilisées dans l’utilisation normale du compte.

Gardez un terminal ouvert. Toutes les données du catalogue et les identifiants sont synthétiques. Le contenu de la Cache API est local à un point de desserte ; un réseau mondial ne constitue pas un cache répliqué partout dans le monde. À la fin, supprimez le Worker, retirez le secret local et déconnectez-vous.

Observer des réponses fraîches du catalogue public

Dans cette étape, vous allez examiner un catalogue synthétique fourni et observer son comportement sans cache. Le jeu de données génère un nouvel UUID pour chaque réponse, ce qui permet de constater la réutilisation sans devoir deviner les délais ni utiliser une base de données.

cd /home/labex/project/public-cache
node --version
npx wrangler --version
cat src/catalog.js

Vous devez obtenir Node.js v22.22.0 et Wrangler 4.131.1. La configuration a installé les dépendances exactes du projet ; pour reproduire une installation existante à partir de son fichier de verrouillage, utilisez npm ci. L’environnement d’évaluation utilise également Miniflare 4.20260730.0, conformément à la date de compatibilité. Le jeu de données varie selon la langue, la catégorie et le client synthétique ; il peut simuler une erreur 503 avec X-Demo-Failure: 1. Ces valeurs servent aux tests et ne sont pas de véritables identifiants.

WORKER_NAME="labex-cache-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<CONFIG
{
  "name": "$WORKER_NAME",
  "main": "src/index.js",
  "compatibility_date": "2026-07-30",
  "workers_dev": true,
  "preview_urls": false
}
CONFIG
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';

function deliver(response, cacheStatus) {
  const headers = new Headers(response.headers);
  headers.set('X-Lab-Cache', cacheStatus);
  // This lab caches inside the Worker, not in the caller's browser.
  headers.set('Cache-Control', 'no-store');
  return new Response(response.body, {status: response.status, headers});
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/health' && request.method === 'GET') {
      return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
    }
    if (url.pathname !== '/api/catalog') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const language = url.searchParams.get('lang') || 'en';
    const category = url.searchParams.get('category') || 'network';
    if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
        [...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
        url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
      return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
    }
    if (request.method !== 'GET') {
      return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
    }
    return deliver(catalog(request, language, category), 'BYPASS');
  }
};
JS
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log

Attendez que le serveur soit prêt avant d’envoyer des requêtes. Si le démarrage est encore en cours, exécutez de nouveau cat dev.log. Gardez ce terminal ouvert afin que ses variables de shell restent disponibles.

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

Les deux requêtes renvoient 200, avec audience à public et des UUID generation différents. X-Lab-Cache: BYPASS signifie que ce gestionnaire n’a ni consulté ni alimenté son cache. L’en-tête Cache-Control: no-store destiné au client empêche le cache du navigateur ou du client d’intervenir dans l’expérience. Utilisez la vérification avant de remplacer cette version de référence.

Mettre en cache uniquement les réponses publiques éligibles

Dans cette étape, vous allez ajouter la consultation et l’écriture via la Cache API. Arrêtez le processus de développement actuel, indiqué par jobs ; l’exemple suppose qu’il s’agit du job 1.

jobs
kill %1
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';

function deliver(response, cacheStatus) {
  const headers = new Headers(response.headers);
  headers.set('X-Lab-Cache', cacheStatus);
  // This lab caches inside the Worker, not in the caller's browser.
  headers.set('Cache-Control', 'no-store');
  return new Response(response.body, {status: response.status, headers});
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/health' && request.method === 'GET') {
      return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
    }
    if (url.pathname !== '/api/catalog') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const language = url.searchParams.get('lang') || 'en';
    const category = url.searchParams.get('category') || 'network';
    if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
        [...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
        url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
      return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
    }
    const keyUrl = new URL('/api/catalog', url.origin);
    keyUrl.searchParams.set('category', category);
    keyUrl.searchParams.set('lang', language);
    const key = new Request(keyUrl, {method: 'GET'});
    const cache = caches.default;
    if (request.method !== 'GET') {
      return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
    }
    // Decide eligibility before lookup: a warm public entry must not mask private work or errors.
    const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
      .some(name => request.headers.has(name));
    if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
    const cached = await cache.match(key);
    if (cached) return deliver(cached, 'HIT');
    const response = catalog(request, language, category);
    if (response.status !== 200 || response.headers.has('Set-Cookie')) {
      return deliver(response, 'BYPASS');
    }
    const stored = response.clone();
    stored.headers.set('Cache-Control', 'public, max-age=10');
    // Await completion here so the next request can observe the write.
    await cache.put(key, stored);
    return deliver(response, 'MISS');
  }
};
JS

La clé utilise l’origine actuelle ainsi qu’une route, une catégorie et une langue fixes. L’ordre des paramètres est canonique, tandis que les deux dimensions de contenu restent distinctes. Les paramètres inconnus et les dimensions dupliquées sont rejetés au lieu de modifier silencieusement le sens de la clé.

L’éligibilité est vérifiée avant la consultation du cache. Les en-têtes Authorization, Cookie et ceux du client synthétique contournent une entrée publique déjà présente. Le jeu de données d’erreur contourne également la consultation, afin qu’une erreur ne soit pas masquée par une réussite mise en cache. Seule une réponse réussie sans Set-Cookie est stockée. Nous la clonons, car les corps des réponses sont des flux, nous attribuons à la copie stockée un TTL de 10 secondes et nous attendons la fin de l’écriture. Les réponses renvoyées conservent no-store ; l’entrée interne de la Cache API possède sa propre politique de cache.

npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log

Attendez que le serveur soit prêt avant d’envoyer des requêtes. Si le démarrage est encore en cours, exécutez de nouveau cat dev.log. Gardez ce terminal ouvert afin que ses variables de shell restent disponibles.

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?category=network&lang=en"
curl -i "http://127.0.0.1:8080/api/catalog?lang=fr&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=printer"

Exécutez les deux premières requêtes dans un délai de dix secondes. La première réponse non mise en cache indique MISS ; la répétition indique HIT et conserve la même valeur generation. L’inversion de l’ordre des paramètres ne modifie pas la clé. Les variantes française et printer utilisent les dimensions demandées et possèdent des entrées indépendantes. Si le TTL expire pendant votre lecture, répétez rapidement une paire de requêtes ; ne supposez pas que le contenu du cache reste disponible indéfiniment.

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Cookie: demo=synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

Les requêtes associées à un client ou à une identité renvoient BYPASS et l’audience synthétique appropriée, sans jamais renvoyer le résultat d’un autre client. L’erreur simulée renvoie 503 BYPASS, même lorsqu’une donnée publique est déjà en cache. Une requête publique ultérieure renvoie toujours la donnée publique, et non l’erreur. Effectuez la vérification avec le serveur local en fonctionnement. Cette procédure exécute également le gestionnaire dans un environnement local isolé ; elle ne modifie pas un cache cloud.

Faire expirer et invalider une entrée du cache local

Dans cette étape, vous allez ajouter une opération d’invalidation authentifiée pour la même clé canonique. Il s’agit d’une suppression dans un centre de données local, et non d’une purge globale. Arrêtez le processus de développement réel avant de modifier le fichier.

jobs
kill %1
umask 077
PURGE_TOKEN=$(openssl rand -hex 24)
printf 'PURGE_TOKEN=%s\n' "$PURGE_TOKEN" > .dev.vars
cat .gitignore

Conservez le secret synthétique en dehors de Git, de la configuration publique, des URL et des journaux. Il protège l’opération DELETE de cet atelier ; ce n’est pas un jeton d’API Cloudflare.

cat > src/index.js <<'JS'
import {catalog} from './catalog.js';

function deliver(response, cacheStatus) {
  const headers = new Headers(response.headers);
  headers.set('X-Lab-Cache', cacheStatus);
  // This lab caches inside the Worker, not in the caller's browser.
  headers.set('Cache-Control', 'no-store');
  return new Response(response.body, {status: response.status, headers});
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/health' && request.method === 'GET') {
      return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
    }
    if (url.pathname !== '/api/catalog') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const language = url.searchParams.get('lang') || 'en';
    const category = url.searchParams.get('category') || 'network';
    if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
        [...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
        url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
      return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
    }
    const keyUrl = new URL('/api/catalog', url.origin);
    keyUrl.searchParams.set('category', category);
    keyUrl.searchParams.set('lang', language);
    const key = new Request(keyUrl, {method: 'GET'});
    const cache = caches.default;
    if (request.method === 'DELETE') {
      if (!env.PURGE_TOKEN) return Response.json({error: 'purge_unconfigured'}, {status: 503});
      if (request.headers.get('Authorization') !== `Bearer ${env.PURGE_TOKEN}`) {
        return Response.json({error: 'unauthorized'}, {status: 401, headers: {'Cache-Control': 'no-store'}});
      }
      const invalidated = await cache.delete(key);
      return Response.json({invalidated, scope: 'this-location'}, {
        headers: {'Cache-Control': 'no-store', 'X-Lab-Cache': 'BYPASS'}
      });
    }
    if (request.method !== 'GET') {
      return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET, DELETE'}});
    }
    // Decide eligibility before lookup: a warm public entry must not mask private work or errors.
    const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
      .some(name => request.headers.has(name));
    if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
    const cached = await cache.match(key);
    if (cached) return deliver(cached, 'HIT');
    const response = catalog(request, language, category);
    if (response.status !== 200 || response.headers.has('Set-Cookie')) {
      return deliver(response, 'BYPASS');
    }
    const stored = response.clone();
    stored.headers.set('Cache-Control', 'public, max-age=10');
    // Await completion here so the next request can observe the write.
    await cache.put(key, stored);
    return deliver(response, 'MISS');
  }
};
JS

DELETE vérifie l’identifiant avant d’appeler cache.delete avec la même clé GET que celle utilisée pour la consultation et le stockage. Le booléen renvoyé indique si une entrée existait ici. Une suppression non autorisée doit laisser l’entrée intacte. Des requêtes envoyées vers un autre point de desserte peuvent encore trouver leur propre entrée.

npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log

Attendez que le serveur soit prêt avant d’envoyer des requêtes. Si le démarrage est encore en cours, exécutez de nouveau cat dev.log. Gardez ce terminal ouvert afin que ses variables de shell restent disponibles.

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer $PURGE_TOKEN"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

Une requête DELETE non autorisée renvoie 401. Une requête DELETE valide renvoie scope: this-location et normalement invalidated: true si l’entrée existe encore. La valeur false est également significative si le TTL court a déjà expiré. Le GET suivant renvoie MISS avec une nouvelle valeur generation. Pour obtenir true, exécutez un GET immédiatement avant le DELETE authentifié.

sleep 11
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

Après onze secondes, un nouveau MISS démontre l’expiration sans suppression explicite. Effectuez la vérification avec le serveur en fonctionnement : l’environnement isolé contrôle la réutilisation, la séparation des dimensions, l’exclusion des requêtes privées et des erreurs, le rejet d’une suppression, la suppression ciblée réussie, la conservation d’une autre clé et l’expiration. Ces vérifications locales contrôlées fournissent des résultats reproductibles sans supposer l’état d’un cache global.

La documentation de la Cache API explique sa portée limitée au centre de données, le comportement des en-têtes de réponse et cache.delete. La Cache API et la mise en cache de la plateforme qui ignore l’exécution du Worker sont deux mécanismes distincts.

Déployer et vérifier les limites du cache

Dans cette étape, vous allez déployer le gestionnaire terminé sur votre compte d’apprentissage. Arrêtez le processus local réel, autorisez cette nouvelle VM, puis vérifiez l’identité du compte.

jobs
kill %1
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_tail:read

Dans le navigateur où vous êtes connecté, ouvrez le lien et saisissez le code affichés, vérifiez que les autorisations et l’accès en arrière-plan sont inchangés, puis sélectionnez votre compte d’apprentissage. Attendez le message de réussite dans le terminal.

npx wrangler whoami --json

Confirmez le nom du compte souhaité. Remplacez YOUR_ACCOUNT_ID par son véritable identifiant ci-dessous, en conservant votre nom unique.

cat > wrangler.jsonc <<CONFIG
{
  "name": "$WORKER_NAME",
  "main": "src/index.js",
  "compatibility_date": "2026-07-30",
  "workers_dev": true,
  "preview_urls": false,
  "account_id": "YOUR_ACCOUNT_ID"
}
CONFIG
npx wrangler deploy
npx wrangler secret bulk .dev.vars
npx wrangler secret list

Le fichier de secrets local n’est pas envoyé par deploy ; la commande explicite bulk crée PURGE_TOKEN en tant que secret_text. Attendez brièvement après le déploiement pour laisser la propagation s’effectuer. Copiez ci-dessous l’URL publique réelle affichée par Wrangler.

APP_URL="https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev"
curl -i "$APP_URL/api/catalog?lang=en&category=network"
curl -i "$APP_URL/api/catalog?category=network&lang=en"
curl -i "$APP_URL/api/catalog?lang=fr&category=network"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"

Les réponses publiques doivent contenir la langue et la catégorie demandées, ainsi que l’audience public. Des répétitions depuis le même point de desserte pendant le TTL peuvent afficher HIT et conserver la même valeur generation ; un autre point de desserte ou l’expiration peuvent légitimement produire MISS. Ne concluez pas que le contenu est partagé mondialement à partir de deux requêtes. Les requêtes privées doivent toujours contourner le cache, et l’erreur doit renvoyer 503 BYPASS.

Dans le même compte Dashboard, ouvrez Compute → Workers & Pages et confirmez la présence du Worker exact ainsi que son URL workers.dev. Utilisez la vérification pour contrôler la propriété, l’association du secret déployé et les limites des réponses. Elle n’effectue aucune invalidation cloud. Le comportement d’invalidation a été testé localement ; cache.delete n’est pas un mécanisme de purge globale. Si le déploiement est encore en cours de propagation, attendez brièvement puis répétez les vérifications des réponses ; en cas d’écart persistant, recherchez sa cause au lieu de l’accepter.

Supprimer le Worker temporaire

Dans cette étape, supprimez le déploiement de cet atelier tout en restant autorisé. Confirmez le nom unique et le compte, puis supprimez uniquement ce Worker.

cat wrangler.jsonc
npx wrangler delete

Lorsque l’invite affiche le nom correspondant, appuyez sur la seule touche y. Wrangler 4.131.1 peut afficher, après la suppression, le diagnostic connu concernant l’authentification legacy de Workers Sites KV. N’élargissez pas les autorisations et ne considérez pas ce message comme une preuve. Actualisez le Dashboard et utilisez la vérification : un inventaire authentifié réussi doit confirmer que ce Worker précis est absent. Conservez le compte d’apprentissage et son sous-domaine. Supprimer le Worker ne signifie pas que chaque entrée du cache a été purgée mondialement ; les entrées synthétiques ont un TTL de dix secondes et aucune application en fonctionnement ne doit rester.

Supprimer le secret local et se déconnecter

Dans cette étape, supprimez l’identifiant temporaire local après avoir vérifié la suppression, puis déconnectez cette VM.

rm .dev.vars
unset PURGE_TOKEN
npx wrangler logout
npx wrangler whoami --json

Exigez explicitement loggedIn: false ; la commande structurée non authentifiée peut se terminer avec un code différent de zéro. Utilisez la vérification et terminez la VM. La session de connexion du navigateur peut rester disponible. Se déconnecter ou terminer la VM ne supprime pas automatiquement un déploiement cloud.

Résumé

Vous avez remplacé la génération non mise en cache du catalogue par une mise en cache explicite des réponses publiques, conservé la séparation des clés de langue et de catégorie, et contourné les requêtes privées et les erreurs avant toute consultation. Vous avez testé des entrées à durée de vie courte ainsi qu’une invalidation authentifiée dans un environnement local contrôlé, puis vérifié l’identité de l’application déployée et les limites de ses réponses sans supposer que le contenu du cache était partagé mondialement.

Le TTL de la copie stockée et la politique de cache du client ont des objectifs différents. Une éligibilité délibérée, des clés complètes et des générations de réponses observables rendent cette distinction vérifiable. Vous avez supprimé le déploiement temporaire et l’identifiant local avant de déconnecter la VM.