Mise en ligne & téléversement
Pourquoi un hébergeur de fichiers répondait 405, ce qu'une fonction peut faire à sa place, et où la conversion WebP a réellement lieu.
Un 405 n'est pas une panne, c'est un refus
L'interface appelait /api/enregistrer depuis le premier jour. En ligne, la réponse était « 405 Method Not Allowed », avec un corps vide et sans en-tête Allow.
Le refus était exact : Cloudflare Pages est un hébergeur de FICHIERS. Il accepte GET et HEAD sur un fichier et refuse le reste. Il n'avait aucun code à exécuter.
| Route | Avant | Après |
|---|---|---|
| POST /api/enregistrer | 405, corps vide | 200 |
| POST /api/image | 405, corps vide | 200 |
| GET /api/journal | 404 — page HTML d'erreur | 200 |
Le serveur Python outils/serveur_admin.py qui implémente ces routes tourne sur le POSTE. Publier dist/ copie des fichiers — cela ne déploie pas un serveur.
Un fichier placé dans functions/ est, lui, du code : Pages l'exécute. C'est toute la différence.
La fonction renvoie désormais ses propres 405, avec l'en-tête Allow et un motif lisible. L'information qui manquait pour comprendre le refus est celle qu'on fournit maintenant.
On ne recompile pas en ligne — et ce n'est pas grave
Le site est pré-compilé par Python : 108 pages engendrées depuis site/. Une fonction Cloudflare ne peut pas exécuter Python, et un déploiement est en lecture seule.
Ce qu'on peut faire, et qui suffit : ranger la modification, et la faire REJOUER par la page au chargement.
/admin en ligne
│
├── enregistrer ──► KV (textes, jetons, journal)
├── téléverser ──► R2 (photos, en WebP)
│
└── au chargement suivant
appliquerRetouches() relit /api/retouches
et repose les valeurs par-dessus le HTML compilé
Les retouches ne s'appliquent QUE dans /admin. Les pages publiques restent du HTML statique pur, sans appel réseau au chargement : leur vitesse et leur lisibilité par les robots sont intactes.
Une modification faite en ligne n'apparaît sur le site public qu'après rapatriement et recompilation. C'est une contrainte assumée, pas un oubli.
Deux stockages, deux rôles
Un déploiement est en lecture seule : la fonction ne peut pas écrire dans les fichiers du site. Tout ce qu'enregistre /admin va donc à côté.
Liaisons déclarées : KAINA, MEDIAS.
| Liaison | Service | Contient |
|---|---|---|
| KAINA | KV | textes retouchés, jetons de design, journal |
| MEDIAS | R2 | les photos téléversées, en WebP |
| Route | Rôle |
|---|---|
| GET /api/journal | les dix dernières entrées du fil d'activité |
| GET /api/retouches | ce qui a été modifié en ligne, à rejouer |
| POST /api/enregistrer | range textes, images et jetons dans KV |
| POST /api/image | reçoit les variantes WebP et les range dans R2 |
Le bucket R2 est propre au projet. Partager celui d'un autre site exposerait ses fichiers à un écrasement : un dépôt par projet, toujours.
Si une liaison manque, la fonction répond 503 avec un message explicite — « le stockage KV n'est pas relié à ce projet » — plutôt qu'un 500 muet qui enverrait chercher l'erreur dans le navigateur alors qu'elle est dans la configuration.
La conversion a lieu dans le navigateur
Servir le JPEG d'origine en une seule taille annulerait toute la stratégie d'image du site : un téléphone téléchargerait 1600 px pour afficher 390.
L'API d'images du runtime Workers convertit très bien — en local. En production, sur un compte sans abonnement Cloudflare Images, elle est absente. Un piège en deux temps : cela fonctionne à l'essai, et échoue une fois en ligne.
Le navigateur a déjà décodé la photo pour l'afficher. La ré-encoder en WebP par canvas.toBlob() est natif depuis Chrome 50, Safari 14 et Firefox 96 — plus de 96 % du parc. Le serveur ne décode rien : il vérifie que ce qu'il reçoit est bien du WebP, sur les OCTETS et non sur l'extension annoncée, puis l'écrit.
Largeurs produites : 480, 768, 1024, 1280, plus la largeur réelle — plafonnées à la taille de la photo. On n'agrandit jamais : une photo de 900 px rendue en 1280 pèse plus lourd pour une image plus floue.
| Variante | Poids | vs source |
|---|---|---|
| source JPEG | 36 503 o | — |
| 480 px | 2 118 o | −94 % |
| 768 px | 3 284 o | −91 % |
| 1024 px | 4 466 o | −88 % |
| 1280 px | 5 712 o | −84 % |
| 1600 px | 8 392 o | −77 % |
Mesure réelle sur une photo de 1600×1067, relevée en production. Un téléphone reçoit 2 118 octets au lieu de 36 503.
Pourquoi <code>sizes</code> compte plus que <code>srcset</code>
C'est sizes, et non srcset, qui décide de la variante téléchargée : le navigateur choisit AVANT d'appliquer le CSS, donc il ne peut pas deviner la largeur d'affichage.
Défaut mesuré : sans sizes, le navigateur suppose 100vw. À 414×896 en dpr 2, il réclamait 1000 px et prenait la variante 1500 px au lieu de 1024. Le rejeu conserve donc le sizes de la balise compilée, qui porte la vraie largeur du cadre.
Pas d'AVIF en ligne. Le navigateur ne sait pas l'encoder via canvas. Les photos ajoutées en ligne sont en WebP seul ; les variantes AVIF sont produites à la compilation locale par avifenc.
La substitution a lieu AVANT l'envoi, pas après
Première version : un script rejouait les retouches après le chargement. Mesuré en ligne sur la page d'accueil — et le défaut est visible à l'œil nu.
0 ms le navigateur peint l'ANCIENNE photo (celle du HTML)
103 ms le script demande /api/retouches
161 ms la réponse arrive, la photo est remplacée
Un sixième de seconde pendant lequel l'ancienne image est visible, puis change sous les yeux du visiteur. Ce n'est pas un cache : c'est l'ordre des opérations. Aucun réglage ne l'aurait supprimé — seul un changement d'endroit le pouvait.
HTMLRewriter réécrit le HTML pendant son envoi, en flux, sans jamais le charger en mémoire (functions/_middleware.js). Le navigateur ne reçoit que la bonne image : il n'a rien à corriger, donc rien ne clignote.
| Rejeu par script | Réécriture au serveur | |
|---|---|---|
| Ancienne image | peinte, puis remplacée | jamais téléchargée |
| Appel réseau | un par page | aucun |
| Ce que lit un robot | l'ancienne image | la bonne image |
| Script sur le site public | 780 octets | aucun |
Le gain n'est pas seulement visuel. Un robot n'exécute pas toujours le JavaScript, et jamais immédiatement : avec le rejeu par script, c'est l'ancienne photo qui était indexée. La réécriture au serveur rétablit la règle — ce que lit un robot est ce que voit un visiteur.
Ce qui est préservé
· Aucune retouche → la page passe sans être analysée, à l'octet près.
· La lecture des retouches est mise en cache dans l'instance (5 s) : une page n'attend pas une lecture KV par image.
· Un échec de lecture laisse passer la page d'origine — une page juste mais périmée vaut mieux qu'une page absente.
· Seules les images sont réécrites : ni le texte, ni la structure.
Le marqueur data-emp posé sur chaque <picture> reste nécessaire : c'est lui qui indique quelle balise correspond à quel emplacement. Coût : environ 275 octets par page.
Deux origines de photo, deux dossiers — et le défaut que cela avait causé
Une photo désignée par un emplacement peut venir de deux endroits, et ils ne se servent pas au même chemin :
- téléversée en ligne → rangée dans R2, servie sous
/media/; - déjà dans le site → fichier compilé, servi sous
/assets/img/.
Le défaut : la fonction ne consultait que les photos téléversées. Choisir une image déjà présente dans la bibliothèque n'avait donc aucun effet — l'emplacement gardait son ancienne photo, quel que soit le choix. Et le défaut était trompeur : l'interface, elle, affichait bien la bonne vignette, puisqu'elle lit la médiathèque injectée dans la page. On voyait donc le bon choix dans le panneau et l'ancienne image sur le site.
La correction : le compilateur publie dist/mediatheque.json — les variantes et les dimensions des 37 photos, 9,4 Ko — que la fonction consulte pour résoudre une photo de la bibliothèque. Elle est mise en cache cinq minutes : elle ne change qu'à la compilation.
La boucle qu'il ne faut pas oublier
Les retouches vivent dans KV, pas dans site/. La prochaine compilation depuis le poste ne les connaît pas et les écrase.
C'est la même famille de défaut que les images perdues : une donnée qui n'existe qu'à un seul endroit, et cet endroit n'est pas la source.
Le bandeau de /admin le dit à chaque chargement : « N retouche(s) en ligne — non encore rapatriées dans les sources ». Le taire ferait croire le site à jour.
curl -s https://kaina-experience.pages.dev/api/retouches > r.json
python3 outils/rapatrier_retouches.py r.json
python3 noyau/compiler.py
bash outils/publier_site.sh
L'outil n'invente jamais de clé : une clé absente des fichiers de langue est signalée, pas créée. En créer une la rendrait absente des autres langues et ferait échouer leur compilation.
Les photos sont téléchargées ET retraitées
Elles repassent par televersement.py, exactement comme un dépôt local : variantes AVIF comprises, alt inscrit dans les quatre langues, entrée de médiathèque complète.
On repart de la plus grande variante en ligne, jamais de la vignette : ce qui est perdu à l'encodage ne se retrouve pas. Le dépôt local devient donc la copie de meilleure qualité — c'est ce qui en fait une vraie sauvegarde.
L'opération est idempotente : relancer l'outil ne recrée pas la photo. Sans ce contrôle, chaque exécution déposait une copie — « …-1800 », puis « …-1800-2 » — alors que cet outil est précisément fait pour être relancé.
L'alt suit la photo dans les quatre langues. La photo est commune à toutes ; sa description doit donc changer partout. Sans cela, l'allemand continuait d'annoncer « Blitzporträt einer Frau » pour une photo de bouquet.
Rien n'est publié tant qu'on n'a pas enregistré
Déposer une photo ne l'applique pas au site. C'est le bouton Enregistrer qui publie, et lui seul.
Le défaut que cela ferme. Un dépôt écrivait aussitôt dans le stockage partagé et dans le journal. La photo devenait donc visible dans /admin, et une entrée était consignée, alors que rien n'avait été validé — et qu'on pouvait encore quitter la page. La page d'accueil, elle, gardait l'ancienne image : deux visuels pour un même site.
déposer une photo
└── les octets vont dans R2 (il faut bien qu'ils vivent)
RIEN dans les retouches, RIEN dans le journal
cliquer sur « Enregistrer »
├── sauvegarde complète, puis rotation
├── écriture au journal
├── écriture des retouches
└── la page se recharge
Quitter /admin sans enregistrer abandonne tout : les modifications ne vivent que dans les variables de l'onglet. Le navigateur demande confirmation avant de les perdre.
Les variantes non retenues restent orphelines dans R2. C'est le bon compromis : quelques kilo-octets inertes que rien ne référence, contre une modification appliquée sans consentement.
Ce qui n'est pas protégé
/admin n'a aucune authentification. C'est la décision prise pour cette démonstration.
L'en-tête X-Robots-Tag: noindex, nofollow, noarchive tient les moteurs de recherche à distance — rien de plus. Quiconque connaît l'adresse peut modifier le site.
Faute de mot de passe, des plafonds bornent les dégâts d'un accident ou d'une boucle automatisée. Ils ne remplacent pas une authentification : ils empêchent qu'un accident coûte cher, pas qu'un visiteur mal intentionné agisse.
| Plafond | Valeur | Porte sur |
|---|---|---|
| MAX_CORPS | 512 Ko | charge JSON d'un enregistrement |
| MAX_IMAGE | 8 Mo | une photo téléversée |
| MAX_JOURNAL | 50 | entrées conservées |
| MAX_RETOUCHES | 400 | clés avant rapatriement obligatoire |
Avant tout usage réel, protégez /admin — Cloudflare Access, ou un contrôle dans la fonction elle-même.