Séparer le frontend et prérendre le site public
Comment le site public est devenu un build prérendu à part, et comment une règle de lint entre lui et l'application est devenue une frontière de paquets.
Jusqu'à la mi-août, tout ce que servait le frontend se trouvait derrière une connexion. Il n'y
avait qu'un bundle côté client et un seul template HTML, qui portait un noindex général puisque
l'application fonctionne sur invitation. Un robot d'indexation qui visite une page construite
ainsi reçoit une coquille vide, et aucune page ne peut avoir son propre titre. Or la visibilité
dans les moteurs de recherche était l'une des raisons de publier un site public avant que le
produit ne soit vendable. La façon dont le site est rendu ne pouvait donc pas attendre les pages
qui en auraient besoin.
En deux jours, j'ai donné au site un build qui produit du vrai HTML. J'ai ensuite découpé le frontend en trois paquets, pour que le site et l'application soient tenus à l'écart l'un de l'autre par la résolution des modules, et non par une convention.
Deux origines, et un seul cookie qui les traverse#
Le site public est servi sur avanserv.com et l'application sur app.avanserv.com. L'apex est
revenu au site parce que c'est la moitié qui doit être trouvée. L'API reste sur l'origine de
l'application, ce qui garde les cookies de session limités à leur hôte et tient le CORS à l'écart
de toutes les requêtes que fait l'application.
Le site doit tout de même renvoyer vers l'application le visiteur déjà connecté qui arrive sur sa page d'accueil. Élargir les cookies de session au domaine parent le permettrait, mais ça remettrait l'identifiant de session à chaque sous-domaine, et une faille de scripting sur une page marketing deviendrait un moyen de détourner la session de quelqu'un. À la place, un seul cookie est rattaché au domaine. Il dit seulement qu'une session existe, et un script inline le lit avant le moindre affichage. Il n'accorde rien, et ses deux modes de défaillance sont inoffensifs : un indice périmé coûte une navigation pour rien, et un indice absent laisse un visiteur connecté sur la page marketing.
Trois passes pour une page#
Le site est un second projet Vite avec sa propre racine, et son build se fait en trois passes. La première est un build client, la deuxième un build côté serveur. La troisième est un court script qui rend chaque route publique et écrit le balisage et le head dans la coquille HTML que le build client vient de produire. C'est parce qu'il réutilise cette coquille produite, et non le template source, que les deux passes s'accordent sur les noms d'assets hachés sans manifeste entre elles. Le build n'a demandé aucune nouvelle dépendance : Vue embarque déjà son moteur de rendu serveur, et bun exécute directement le TypeScript du script.
Le head est une fonction pure des métadonnées de la route. Le prérendu et la navigation côté client l'appellent tous deux, si bien qu'un titre écrit dans le HTML ne peut pas diverger de celui que voit le visiteur après avoir cliqué sur un lien.
La racine décide de la feuille de style#
La mesure qui a tranché la structure portait sur le CSS. Tailwind v4 choisit les fichiers à analyser à partir de la racine du projet Vite, et non de l'emplacement de la feuille de style ni du graphe de modules. Avec le site placé à la même racine que l'application, il livrait 123 Ko de CSS, soit 957 sélecteurs de classe, pour une page qui utilisait huit classes. Placé à sa propre racine, il en livrait 32 Ko.
Deux solutions moins coûteuses ont échoué en chemin. Couper la détection automatique a fait disparaître sans un mot les propres classes du site, et exclure les répertoires de l'application n'a retiré que 12 pour cent. L'essentiel du poids venait de la couche de composants partagée, qui est produite dans tous les cas.
D'une règle de lint à une frontière de paquets#
Après le premier jour, la séparation n'était encore qu'une règle de lint. Le frontend était un
seul paquet avec un seul manifeste, donc une page marketing qui importait le store
d'authentification de l'application se résolvait, se construisait et partait en production. Rien
ne l'empêchait, à part no-restricted-imports et la personne qui relisait le diff.
Le lendemain, c'étaient trois paquets dans un même workspace : l'application, le site et un kit
qui contient ce qu'ils partagent. Ce kit, c'est le design system, les composants qui n'ont besoin
d'aucune session et les utilitaires de test. La racine du workspace ne déclare aucune dépendance
d'exécution, et bun est fixé sur son linker isolé. C'est cette combinaison qui rend la frontière
structurelle. Avec le hoisting par défaut, les dépendances de chaque paquet sont remontées dans un
node_modules commun, que la résolution de Node atteint depuis n'importe quel paquet en remontant
l'arborescence : le site aurait donc toujours trouvé la bibliothèque d'état de l'application.
Avec le linker isolé, le node_modules du site ne contient que Vue, le routeur et le kit, et
résoudre quoi que ce soit d'autre depuis le site échoue net. Je l'ai vérifié dans un workspace
jetable avant de déplacer près de huit cents fichiers.
J'ai choisi les fichiers qui partaient dans le kit en parcourant le graphe d'imports, et non en
lisant les noms de répertoires, puisque c'étaient justement les noms de répertoires qui étaient
faux. Le kit se désigne aussi par son nom de paquet, jusque dans ses propres fichiers. L'alias
habituel @/ veut dire "les sources de ce paquet", il pointe donc vers un répertoire différent
dans chacun des trois programmes, et un module du kit qui écrivait @/utils/cn aurait demandé à
l'application un fichier qu'elle n'a pas.
J'ai contrôlé le résultat sur ce que le build produit, parce que chaque défaillance ici est silencieuse. J'ai construit les deux feuilles de style avant le déplacement et je les ai comparées règle par règle après. Celle du site est ressortie identique à l'octet près, jusqu'à son empreinte. Celle de l'application a perdu sept utilitaires, tous des écritures nues qu'aucun élément ne portait. Soixante-treize classes écrites uniquement dans les sources du kit sont apparues dans son CSS, ce qui montre que le kit est bien analysé.
Ce qui se cachait le mieux#
Le pire bug du découpage passait le build de production. Le kit expose ses dossiers via la carte
exports de son paquet, et une telle carte substitue un chemin sans jamais résoudre de fichier
index. L'import d'un dossier de composants se résolvait donc vers un répertoire. vite build
l'acceptait et livrait, tandis que le serveur de développement répondait par une 500 et que la
page ne s'affichait jamais. La seule chose qui échouait était un test navigateur, et son échec
ressemblait à une violation d'accessibilité sur une page qui n'avait jamais compilé. Une spec
résout désormais chaque import du kit dans le workspace à travers la carte, et échoue sur un
répertoire ou un fichier manquant.
Le reste de cette famille, c'est tout code qui résout un chemin à l'exécution plutôt que par le système de modules. Il continue de fonctionner et de signaler un succès, mais il vise la mauvaise arborescence. Le générateur d'icônes écrivait la copie du site un répertoire trop bas et annonçait un succès. Le plugin de couverture rejette les fichiers hors de son répertoire de travail avant de lire sa liste d'inclusion, si bien que chaque composant parti dans le kit est sorti de la couverture end-to-end sans un mot. Et un fichier d'exclusion du formateur resté en l'état a reformaté le client d'API généré, après quoi une vérification de dérive signalait un changement d'API qui n'avait jamais eu lieu. En cherchant les anciens chemins dans tout le repo, commentaires et documentation compris, j'ai trouvé une quarantaine d'endroits de ce genre.
Quelques jours plus tard, un piège du même ordre est apparu dans le point d'entrée client. Un client qui hydrate le balisage prérendu et un client qui le jette pour tout rendre à nouveau sont identiques pour toutes les assertions ordinaires : même texte, même structure, mêmes résultats d'accessibilité. Seule l'identité des noeuds les distingue. La spec garde maintenant une référence vers un élément du balisage prérendu, démarre le client, et vérifie que l'élément est toujours le même objet.
Ce que le build refuse désormais#
La vérification du site lit les fichiers produits, pas la configuration. Elle contrôle que chaque route publique a produit un fichier qui porte son propre titre principal en dehors de tout script, son propre titre d'onglet et sa propre URL canonique. Elle refuse aussi un point de montage vide, tout paquet venant de l'application, un body qui ne défile pas, et la redirection des visiteurs connectés ailleurs que dans le template du site. Un job de CI construit les deux moitiés et lance cette vérification. Jusque-là, rien dans la CI n'avait jamais lancé un build de production du frontend, et un build cassé serait parti sans que personne ne le voie.
Les assets produits par le site ont aussi des budgets en octets, que le build fait respecter. Chaque hausse doit être argumentée dans un registre, à côté du chiffre.
Le découpage a un coût. Il y a deux artefacts à déployer là où il y en avait un, et chaque environnement construit les siens, parce que les URL canoniques sont écrites dans le HTML prérendu. Le site a aussi besoin d'une vraie 404, puisque le fallback de page unique dont dépend l'application répondrait à chaque faute de frappe par la page d'accueil et un code 200.