6 min de lecture

Interroger le CMS une fois, et non une fois par page

Ce site demandait à Strapi le même contenu pour chaque page qu'il générait. Désormais un loader récupère chaque source une fois par build et chaque page lit un magasin local. Voici ce qui a changé, ce que cela a supprimé, et les trois pièges rencontrés.

AstroTypeScriptStrapi

Chaque page de ce site est prérendue. Il n'y a pas de serveur au moment de la requête, donc toute lecture du CMS se fait pendant le build — cela a toujours été vrai et le reste. Ce qui n'allait pas, c'était le nombre de fois où cela se produisait.

La mise en page partagée affiche un pied de page qui a besoin de mon profil. Le profil était donc récupéré une fois par page. Pas une fois par build : une fois par page. Ce site génère 136 pages, si bien qu'un type de contenu qui change peut-être deux fois par an était tiré de Strapi 136 fois par déploiement. Les projets étaient récupérés par l'index des projets, puis à nouveau par chaque page de détail, puis encore par l'index de recherche. Deux fonctionnalités avaient fini par se doter de caches de promesses écrits à la main uniquement pour masquer le problème.

flowchart LR
  p1["/ "] --> d1["data.ts"]
  p2["/projets"] --> d2["data.ts"]
  p3["…134 autres pages"] --> d3["data.ts"]
  d1 --> cms[("Strapi")]
  d2 --> cms
  d3 --> cms

Un loader par source

Le Content Layer d'Astro inverse cette logique. Au lieu qu'une page tire les données au moment du rendu, on déclare une collection dotée d'un loader, et ce loader s'exécute une fois par synchronisation — avant tout rendu — en écrivant ses résultats dans un magasin de contenu local. Les pages lisent ensuite ce magasin.

Il y a maintenant quatorze collections, une par source du CMS. Tout content.config.ts n'est qu'un manifeste ; la récupération reste dans les modules qui l'ont toujours portée.

flowchart TD
  subgraph sync["Synchronisation — une fois par build"]
    loader["Loader"] --> api["api.ts"]
    api --> cms[("Strapi")]
    loader --> store[("Magasin de contenu")]
  end
  subgraph render["Rendu — une fois par page"]
    pages["136 pages"] --> store
  end

Les fonctions d'accès ont gardé leurs noms et leurs signatures : pas une seule page ni un seul composant n'a été modifié. getProfile(locale) renvoie toujours un profil — elle lit simplement une entrée au lieu d'émettre une requête.

Les identifiants d'entrée portent la locale : en/abc123, fr/abc123. Un consommateur choisit une locale par un filtre sur le préfixe, et comme l'identifiant est la locale plus la clé, il n'existe aucun champ de locale séparé susceptible de le contredire. Les types uniques se passent de clé et utilisent un simple en / fr, ce qui transforme la lecture en accès direct plutôt qu'en recherche.

Un détail qui paraît tatillon mais ne l'est pas : chaque entrée stocke un champ order explicite, tiré de sa position dans la réponse déjà triée de l'API. L'ordre d'itération du magasin est un détail d'implémentation, et s'y fier signifierait que le tri demandé à Strapi pourrait cesser discrètement de survivre au trajet.

Trois choses qui ont disparu

Le plus intéressant dans cette migration, c'est la quantité de code supprimée plutôt qu'ajoutée.

Les caches sont partis. Les deux caches de promesses n'existaient que pour éviter des récupérations répétées. Avec le magasin, il n'y a plus rien à mémoïser : ils ont disparu, ainsi que les commentaires expliquant pourquoi ils étaient nécessaires.

Les requêtes filtrées en doublon sont parties. « Donne-moi seulement les projets mis en avant » était une seconde requête avec un filtre. C'est désormais un .filter() sur un ensemble que le loader possède déjà. Idem pour les compétences, idem pour les temps forts de la frise sur la page d'accueil.

Le repli de traduction est devenu honnête. Un document sans ligne pour une locale signifiait auparavant un 404 de Strapi, intercepté par classe d'erreur, avec repli sur la locale par défaut. Cela signifie maintenant qu'il n'y a pas d'entrée à fr/abc123. Une donnée absente exprimée comme une absence, et non comme une exception :

const localized = await getEntry('projects', `${locale}/${canonical.documentId}`);
return localized?.data ?? canonical;

Trois pièges

astro check exige désormais un CMS joignable, ce qui m'a surpris. La vérification de types ne dépend pas des données — getCollection('projects') est typé depuis le schéma de la collection, pas depuis les lignes. Mais astro check lance d'abord une synchronisation, et celle-ci fait deux choses à la fois : elle génère les types de contenu, ce qui ne demande aucun CMS, et elle exécute tous les loaders, ce qui en demande un. Il n'existe aucun moyen de ne réclamer que la première.

flowchart LR
  check["astro check"] --> sync["astro sync"]
  sync --> types["Génération des types<br/>(sans CMS)"]
  sync --> loaders["Exécution des loaders<br/>(CMS requis)"]
  types --> tsc["Vérification de types"]

La CI détient donc maintenant de vraies informations d'identification en lecture. Le même mécanisme explique une gêne plus modeste : en développement, une modification du CMS exige un redémarrage, pas un rafraîchissement, puisque les loaders s'exécutent à la synchronisation.

TypeScript refuse de donner une signature d'index à une interface. Le parseData d'Astro est typé TData extends Record<string, unknown>, et TypeScript accorde des signatures d'index implicites aux types mappés et aux alias de types littéraux — mais jamais à une interface, ni au travers d'un simple alias vers une interface. La moitié de mes types de contenu sont des interfaces. La correction tentante est de réécrire chacun en { [K in keyof T]: T[K] }, ce qui fonctionne et ne perd rien. La bonne correction était de cesser de laisser une contrainte générique dicter la forme de mes types de domaine : retirer la contrainte de mes propres fabriques et convertir une seule fois, à la frontière.

astro:content est réservé au serveur, et c'est une fuite qui n'attend qu'un prétexte. Ce dépôt en avait déjà connu une : un îlot de graphique importait un module qui atteignait le client Strapi et, à travers lui, un module virtuel côté serveur ; l'îlot échouait à s'hydrater avec une 500. Le commentaire qui consigne l'incident est toujours dans le fichier. Cette migration a recréé le même danger par un chemin neuf, puisque chaque module de données importe désormais astro:content.

Les imports de type seul sont effacés au build, donc sans danger — c'est la seule raison pour laquelle la frontière existante a tenu. C'est bien trop subtil pour être laissé à la mémoire : il y a maintenant un test qui parcourt le graphe d'imports depuis chaque point d'entrée client et échoue sur tout import à l'exécution d'un module réservé au serveur. Sa première version passait alors qu'une violation réelle se trouvait dans l'arbre, parce qu'elle ne vérifiait que les imports directs d'une seule extension de fichier. Un garde-fou qu'on n'a jamais vu échouer n'est pas un garde-fou.

Le résultat

Quatorze loaders, chacun s'exécutant exactement une fois par build ; 796 entrées de collection réparties sur quatre locales ; 136 pages. Le build n'est pas spectaculairement plus rapide — Strapi tourne sur la même machine et n'a jamais été le goulot d'étranglement — mais la forme est enfin juste, et la quantité de code qui n'existait que pour contourner l'ancienne est désormais nulle.