Le rendu serveur

Comment une page du site est rendue : PageRenderer passe par passe, tous les fichiers résolus en une requête, une URL choisie selon le lecteur, les avatars et le logo, des blocs qui restent synchrones, l’alternance calculée sans mutation, et ce que coûte une page.

Précédemment. Chaque type de bloc a une définition : un éditeur client, un rendu serveur. Le registre en exige une par type ; le constructeur est contrôlé, ses aperçus résolvent leurs fichiers sur la route privée.

Les parcours du chapitre#

Deux parcours traversent ce chapitre : le visiteur qui lit une page de contenu, et celui qui parcourt les présentations des instructeurs, où le même rendu est appelé plusieurs fois.

Acteur : Visiteur

  1. 1

    La page lit son contenu, puis le rendu résout tous les fichiers de la page en une requête

    page /about

    • Session supprimée

    Lit : MediaAsset, PageVisibility, Session, SitePage, SiteSettings, User

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    page /about apps/web/src/app/(public)/about/page.tsx:33
    • editorialAccess apps/web/src/features/editorial/editorialGate.tsx:102
      • draftAccess apps/web/src/features/editorial/editorialGate.tsx:71
        • viewerIsAdmin apps/web/src/features/editorial/editorialGate.tsx:59
          • getSessionFromRequest packages/backend/src/modules/auth/getSessionFromRequest.ts:86
            • prisma.session.deleteMany packages/backend/src/modules/auth/getSessionFromRequest.ts:98Session supprimée

  2. 2

    Le navigateur charge chaque image par la route publique, qui vérifie qu’elle est placée

    GET /api/media/public/r2/[...path]

    Lit : Course, Discipline, Event, Gallery, GalleryItem, MediaAsset, PageMediaReference, PageVisibility, Post, Seminar

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    GET /api/media/public/r2/[...path] apps/web/src/app/api/media/public/r2/[...path]/route.ts:49
    • isPubliclyPlaced packages/backend/src/modules/media/services/publiclyPlaced.service.ts:56
      • isRefLive packages/backend/src/modules/media/services/publiclyPlaced.service.ts:27
        • prisma.course.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:33Course lueDiscipline lue
        • prisma.discipline.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:31Discipline lue
        • prisma.event.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:40Event lue
        • prisma.pageVisibility.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:45PageVisibility lue
        • prisma.post.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:42Post lue
        • prisma.seminar.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:37Seminar lue
      • prisma.galleryItem.count packages/backend/src/modules/media/services/publiclyPlaced.service.ts:57Gallery lueGalleryItem lue
      • prisma.pageMediaReference.findMany packages/backend/src/modules/media/services/publiclyPlaced.service.ts:62PageMediaReference lue
    • prisma.mediaAsset.findFirst apps/web/src/app/api/media/public/r2/[...path]/route.ts:69MediaAsset lue

Le rendu, sur le serveur 🖥️#

Une page publique est un composant serveur : il s’exécute sur le serveur, lit la base directement, et envoie au navigateur du HTML déjà construit. Le visiteur reçoit le texte et les balises <img> d’un coup, sans attendre un JavaScript qui irait chercher le contenu.

La page « L’association » fait quatre choses : décider si le visiteur peut la voir (publiée, ou aperçu d’un administrateur), lire la ligne, la passer au filtre de lecture, la confier au rendu.

Une page de contenu
TSX
apps/web/src/app/(public)/about/page.tsx
empty code block

parsePageContentV1 (chapitre 1) garantit un contenu valide et assaini ; la page n’a aucun cas d’erreur à prévoir. Tout le reste est le travail de PageRenderer.

PageRenderer, passe par passe#

PageRenderer est un composant serveur asynchrone : une fonction async qui rend du JSX. Avant de rendre le moindre bloc, il prépare tout ce dont les blocs auront besoin.

Il lit la base et résout les médias : il ne doit jamais partir dans le code du navigateur. Son fichier commence par import "server-only", qui fait échouer le build si un composant client l’importe, et il ne figure pas dans l’entrée @features/page-builder que les formulaires d’administration utilisent pour le constructeur (partie « Étude de cas », chapitre « Lancer, vérifier, écrire »).

PageRenderer — préparer, puis rendre

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Une page vide ne coûte rien
TS
apps/web/src/features/page-builder/PageRenderer.tsx
guided extract: lines 72-129focus lines 3-4
// ⚠ Fichier introuvable : apps/web/src/features/page-builder/PageRenderer.tsx// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.

Puis chaque bloc est rendu par la View de sa définition, avec ce qui a été préparé :

Chaque bloc, avec ce qui a été préparé
TSX
apps/web/src/features/page-builder/PageRenderer.tsx
empty code block

Le cast est le même que dans l’éditeur (chapitre 3), pour la même raison.

Des blocs qui restent synchrones#

Pourquoi ne pas laisser chaque bloc chercher ses propres fichiers ? Deux raisons.

Le nombre de requêtes. Une galerie de trente photos ferait trente requêtes, une page de cinq galeries cent cinquante. Rassembler d’abord les identifiants, puis les résoudre en une requête (WHERE id IN (…)), fait toujours une requête, quel que soit le nombre de fichiers. C’est le problème dit « N+1 » : une requête pour la liste, puis une par élément.

La simplicité des blocs. Un composant serveur peut être asynchrone, mais chaque bloc deviendrait alors une petite page, avec ses lectures, ses erreurs et ses attentes. Ici, une View est un composant ordinaire : elle reçoit son bloc et une fonction qui lit dans une table déjà remplie.

Le bloc média-texte cherche son média
TSX
apps/web/src/features/page-builder/blocks/media-text/view.server.tsx
empty code block

Trois sortes de médias, trois lectures — toutes synchrones.

Le patron se généralise : rassembler les besoins, résoudre en une fois, distribuer une fonction de lecture. Les composants restent simples et le nombre de requêtes ne dépend plus de la taille de la page.

Ce qu’on résout, et ce qu’on omet#

La résolution ne rend pas tous les fichiers demandés :

resolveMediaByIds — ce qui peut s’afficher

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Chaque identifiant a une réponse
TS
packages/backend/src/modules/media/services/resolveMediaByIds.service.ts
guided extract: lines 50-97focus line 7
import type { Prisma, PrismaClient } from '@prisma/client';import type { ResolvedMedia } from '@contracts/page';import { buildMediaProxyUrl } from '@backend/modules/media/helpers/media-url';/* ─────────────────────────────────────────────────────────────────────── *//*  Audience                                                               *//* ─────────────────────────────────────────────────────────────────────── *//** * Pour qui les URLs sont-elles construites ? * *   - `admin`  : les routes de proxy admin-gated *                (`/api/media/r2/<path>` pour R2). C'est ce qui est *                consommé par le builder en édition, les NodeViews, *                le MediaListEditor, et les previews dans l'admin. * *   - `public` : les routes publiques `/api/media/public/r2/<path>`, qui *                vérifient qu'un `PageMediaReference` existe avant de servir. * * Default `'admin'` pour ne pas casser les call sites existants * (procédure tRPC `media.resolveByIds`). */export type MediaUrlAudience = 'admin' | 'public';/* ─────────────────────────────────────────────────────────────────────── *//*  resolveMediaByIds                                                      *//* ─────────────────────────────────────────────────────────────────────── *//** * Résout une liste de `mediaId` (cuid stable) vers leurs informations * de rendu (URL + métadonnées), pour l'audience donnée. * * ─── Service neutre, sans auth ───────────────────────────────────────── * * Appelable : *   - depuis la procédure tRPC `media.resolveByIds` (admin) — passer *     `audience: 'admin'` (ou laisser le default) *   - depuis le RSC `PageRenderer` (public) — passer `audience: 'public'` * * Le filtre `status === 'published'` reste appliqué dans tous les cas : * c'est lui qui matérialise la règle « ce qui est rendu depuis la * bibliothèque ne peut être pris que sous published ». * * ─── Retour ──────────────────────────────────────────────────────────── * * `Record<mediaId, ResolvedMedia | null>` indexé par tous les mediaIds * d'entrée — les ids absents de la DB ou non-published rendent `null`. */export async function resolveMediaByIds(  db: PrismaClient | Prisma.TransactionClient,  mediaIds: readonly string[],  audience: MediaUrlAudience = 'admin',): Promise<Record<string, ResolvedMedia | null>> {  const byId: Record<string, ResolvedMedia | null> = {};  for (const id of mediaIds) byId[id] = null;  if (mediaIds.length === 0) return byId;  const assets = await db.mediaAsset.findMany({    where: {      id: { in: [...mediaIds] },      status: 'published',      // Un fichier jeté (sa ligne a suivi l'objet dans la corbeille) ne s'affiche plus nulle part.      NOT: { fullPath: { contains: '/bin/.trash/' } },    },    select: {      id: true,      fullPath: true,      mimeType: true,      width: true,      height: true,      duration: true,    },  });  for (const asset of assets) {    const isVideo = asset.mimeType.startsWith('video/');    const isAudio = !isVideo && asset.mimeType.startsWith('audio/');    const isImage = !isVideo && !isAudio && asset.mimeType.startsWith('image/');    // L'affiche d'une vidéo suit la MÊME audience que la vidéo : construite sur la route admin, elle    // répondait 401 à tout visiteur d'une page publique.    const url = buildMediaProxyUrl(asset, audience);    byId[asset.id] = {      url,      kind: isVideo ? 'video' : isAudio ? 'audio' : isImage ? 'image' : 'document',      posterUrl: isVideo ? `${url}&as=poster` : null,      mimeType: asset.mimeType,      fileName: lastSegment(asset.fullPath),      width: asset.width,      height: asset.height,      duration: asset.duration,    };  }  return byId;}/* ─────────────────────────────────────────────────────────────────────── *//*  Helpers                                                                *//* ─────────────────────────────────────────────────────────────────────── *//** * Extrait le dernier segment d'un path (le « nom de fichier »). * * Sert de label de repli côté View quand l'éditeur n'a pas saisi de * title/label/caption. Tolérant aux trailing slashes. */function lastSegment(fullPath: string): string {  const trimmed = fullPath.replace(/\/+$/, '');  const idx = trimmed.lastIndexOf('/');  return idx === -1 ? trimmed : trimmed.slice(idx + 1);}

Un fichier repassé en attente ou jeté après avoir été placé disparaît de la page au rendu suivant. La page ne casse pas : la View omet ce qui est null. En pratique, les deux cas sont rares, parce que l’enregistrement et la bibliothèque les empêchent (chapitre 5) ; le rendu ne compte pas dessus.

Une URL pour qui ?#

Le même fichier a deux URLs, selon qui le regarde :

Deux routes, une fonction
TS
packages/backend/src/modules/media/helpers/media-url.ts
export function buildMediaProxyUrl(  asset: { fullPath: string },  audience: MediaUrlAudience = 'admin',): string {  const encoded = asset.fullPath.split('/').map(encodeURIComponent).join('/');  const prefix = audience === 'public' ? '/api/media/public/r2' : '/api/media/r2';  return `${prefix}/${encoded}?variant=large`;}

Audience

Route

Ce qu’elle vérifie

Utilisée par

admin
/api/media/r2/…
Une session, et le droit de lire le fichier (son espace, ses groupes).
L’éditeur, les aperçus, la bibliothèque.
public
/api/media/public/r2/…
Le fichier est validé ET placé dans un contenu PUBLIÉ ou une galerie publique.
Les pages du site.

Le rendu public demande l’audience public (la valeur par défaut de PageRenderer). Une URL privée sur une page publique ne marcherait que pour un visiteur connecté qui a le droit de lire le fichier — c’est-à-dire, presque toujours, pour l’administrateur qui vérifie sa page, et pour personne d’autre. Ce défaut est invisible à celui qui teste connecté : l’affiche des vidéos en était l’exemple.

Une page publique se vérifie déconnecté, dans une fenêtre privée. Connecté, on voit ce que voit un administrateur, pas ce que voit un visiteur.

Une page a pourtant une raison de demander l’audience admin : le brouillon. Ses fichiers sont recensés dès son premier enregistrement, mais la route publique ne les sert pas tant que le contenu n’est pas publié (chapitre 5). La page d’un brouillon répond 404 à un visiteur ; pour un administrateur, elle s’affiche avec le badge « Page non publiée », et ses fichiers passent par la route privée :

apps/web/src/app/(public)/disciplines/[slug]/page.tsx · extrait
tsx
const access = await draftAccess(isLive(discipline.publicationDate));if (!access) notFound();// …<PageRenderer content={description} audience={audienceFor(access)} />

draftAccess rend "public" pour un contenu publié, "preview" pour un brouillon vu par un administrateur, null sinon. Les aperçus du tableau de bord rendent toujours en admin.

?variant=large demande la déclinaison de 1 200 pixels d’une image, fabriquée au besoin par la route (partie « Stockage et médias ») ; &as=poster, l’affiche d’une vidéo.

Un avatar n’est pas un fichier de la bibliothèque : c’est une clé de stockage rangée dans . Il a sa propre route publique :

L’URL d’un avatar
TS
packages/backend/src/modules/media/services/resolveAvatarsByUserIds.service.ts
  for (const user of users) {    if (!user.avatar) continue; // pas d'avatar → reste null (placeholder)    // Route publique dédiée, et non le proxy général : celui-ci exige une    // session sans condition (il sert aussi des documents personnels), donc    // il rendait tout avatar invisible aux visiteurs anonymes — ici comme sur    // la page des instructeurs.    const url = `/api/media/public/avatar/${encodeURIComponent(user.id)}`;

L’URL porte l’identifiant du membre, pas la clé du fichier : si le membre change d’avatar, l’URL ne change pas, et chaque page qui la montre suit. Le logo du site suit le même principe, avec une URL versionnée par la date de modification des réglages (chapitre 7).

La route des avatars sert l’avatar de n’importe quel membre : un avatar est fait pour représenter son titulaire, à côté de son nom (un commentaire, l’auteur d’une actualité). Ce qui est interdit, c’est de le poser dans un contenu à sa place : un contenu ne montre que l’avatar de son auteur (chapitre 6).

L’alternance, sans mutation#

Les blocs média-texte alternent : le premier place son média à gauche, le deuxième à droite, et ainsi de suite, en ne comptant que les blocs média-texte. La façon naturelle d’écrire cela est un compteur :

// Ce que le projet n’écrit PASlet rank = 0;content.blocks.map((block) => {  const side = block.type === "media-text" ? (rank++ % 2 === 0 ? "left" : "right") : undefined;  // …});

Le React Compiler du projet l’interdit : un rendu ne doit modifier aucune variable extérieure, parce que le compilateur se permet de mémoïser, de réordonner ou de rejouer des rendus. Le côté est donc calculé à partir de la position :

Le côté, d’après le rang
TSX
apps/web/src/features/page-builder/PageRenderer.tsx
empty code block

Le rang d’un bloc est sa position dans la liste des seuls blocs média-texte. Le résultat ne dépend que des données : le même contenu donne toujours les mêmes côtés, dans l’éditeur comme sur le site. PageBuilder fait exactement le même calcul pour ses aperçus.

Ce que coûte une page#

Lecture

Requêtes

La ligne de contenu (la page)
1
Tous les fichiers de la bibliothèque
1, quel que soit leur nombre
Tous les avatars
1, quel que soit leur nombre
Le logo
1

Quatre requêtes pour une page entière. Une limite reste : la page des instructeurs rend un PageRenderer par présentation, et chacun refait ses lectures — trois requêtes par instructeur, dont la ligne des réglages relue à chaque fois. Pour une dizaine d’instructeurs, c’est négligeable ; pour une centaine, il faudrait préparer les fichiers de toutes les présentations ensemble et les passer à chaque rendu.

Récapitulatif#

Question

Réponse du projet

Où une page est-elle rendue ?
Sur le serveur : PageRenderer est un composant serveur asynchrone, le navigateur reçoit du HTML.
Combien de requêtes pour les fichiers d’une page ?
Une, pour tous ; une autre pour tous les avatars.
Comment un bloc trouve-t-il ses fichiers ?
Par une fonction synchrone, resolveMedia, qui lit une table préparée : les View restent ordinaires.
Quels fichiers s’affichent ?
Les fichiers validés, hors corbeille ; les autres sont omis sans casser la page.
Quelle URL sur une page publique ?
Celle de la route publique (audience public), pour le fichier comme pour l’affiche d’une vidéo.
Comment les blocs média-texte alternent-ils ?
Le côté est calculé d’après le rang du bloc parmi les blocs média-texte, sans compteur : le React Compiler interdit toute mutation pendant un rendu.

À suivre#

La page s’affiche. Mais la route publique ne sert un fichier que s’il est « placé » : comment sait-elle qu’un fichier l’est ? Par une table que chaque enregistrement tient à jour, avec la même extraction que celle du rendu.

Chapitre suivant : le recensement des fichiers. L’extraction exhaustive, la synchronisation dans la transaction, et tout ce qui s’appuie sur elle : la route publique, la corbeille, le changement de statut.

Sur cette page