Chapitre 9

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.

Routes servies
4
sur la fonction Cloudflare
Largeurs WebP
5
plafonnées à la taille réelle
Photo de 1600 px
−94 %
36 503 → 2 118 o sur téléphone
Authentification
aucune
démonstration assumée
§1

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.

RouteAvantAprès
POST /api/enregistrer405, corps vide200
POST /api/image405, corps vide200
GET /api/journal404 — page HTML d'erreur200

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.

§2

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.

§3

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.

LiaisonServiceContient
KAINAKVtextes retouchés, jetons de design, journal
MEDIASR2les photos téléversées, en WebP
RouteRôle
GET /api/journalles dix dernières entrées du fil d'activité
GET /api/retouchesce qui a été modifié en ligne, à rejouer
POST /api/enregistrerrange textes, images et jetons dans KV
POST /api/imagereç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.

§4

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.

VariantePoidsvs source
source JPEG36 503 o
480 px2 118 o−94 %
768 px3 284 o−91 %
1024 px4 466 o−88 %
1280 px5 712 o−84 %
1600 px8 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.

§5

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 scriptRéécriture au serveur
Ancienne imagepeinte, puis remplacéejamais téléchargée
Appel réseauun par pageaucun
Ce que lit un robotl'ancienne imagela bonne image
Script sur le site public780 octetsaucun

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.

§6

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.

§6-bis

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.

§7

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.

PlafondValeurPorte sur
MAX_CORPS512 Kocharge JSON d'un enregistrement
MAX_IMAGE8 Moune photo téléversée
MAX_JOURNAL50entrées conservées
MAX_RETOUCHES400clés avant rapatriement obligatoire

Avant tout usage réel, protégez /admin — Cloudflare Access, ou un contrôle dans la fonction elle-même.