Commit 90bda15b authored by Kourser's avatar Kourser
Browse files

Le premier affichage en une seconde et demie, et sans Google

Le premier chargement pesait 456 Ko compressés — la moitié de plus que ce
qu'autorise EX-12 — et le cockpit ouvert par un lien paraissait en 3,2 s sur
un réseau mobile ordinaire. Mesuré dans un vrai navigateur bridé, pas estimé.

Chaque écran se charge désormais quand on l'ouvre, le dictionnaire anglais
seulement pour qui lit en anglais, et l'interface part précompressée, son
code gardé un an par le navigateur. Arrivé par un lien de crise, la coque et
l'écran demandé se téléchargent pendant que la session se vérifie ; un écran
déjà là est rendu sans suspendre — React en retenait sinon la révélation
trois cents millisecondes.

Les polices ne viennent plus de Google Fonts. Leur feuille bloquait
l'affichage : une seconde de page blanche en 3G, une page blanche tout court
sur un réseau qui ne joint pas Google, et l'adresse de chaque poste de crise
donnée à un tiers. La politique de contenu n'admet plus d'origine tierce.

87 Ko au premier chargement, 1,5 s pour le cockpit sur mobile bridé.
`pnpm budget` échoue au-delà de 300 Ko ; la compression et le cache de ce que
sert l'instance sont éprouvés à chaque vérification.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 1ec6c163
Loading
Loading
Loading
Loading
+5 −1
Changes for AVIS.md: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -96,7 +96,11 @@ contamination croisée :
| [Excalidraw](https://excalidraw.com) | MIT | Canevas des tableaux blancs |

Les dépendances applicatives (Fastify, React, zod, `postgres`, TanStack Query…)
sont sous MIT ou équivalent. PostgreSQL est sous licence PostgreSQL, Redis sous
sont sous MIT ou équivalent. Les polices de l'interface — Archivo, Source Serif 4
et JetBrains Mono — sont servies par l'instance elle-même, et non plus par Google
Fonts ; elles sont sous **SIL Open Font License 1.1**, qui autorise de les
embarquer et de les redistribuer avec le logiciel, pourvu qu'elles ne soient pas
vendues seules. Leur licence accompagne chaque paquet `@fontsource`. PostgreSQL est sous licence PostgreSQL, Redis sous
licence Redis, MinIO sous **AGPL-3.0** — ce dernier point est sans conséquence
ici : Kastell dialogue avec un stockage objet par l'API S3, derrière un
adaptateur, et n'incorpore aucun code MinIO. Tout déploiement peut lui
+27 −6
Changes for apps/api/src/serveur.ts: 27 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -49,6 +49,12 @@ declare module "fastify" {
export interface OptionsServeur {
  /** Utilisé par la suite de vérification : les traces masqueraient le rapport. */
  silencieux?: boolean;
  /**
   * Le dossier de l'interface construite, quand ce n'est pas « web » à côté du
   * serveur : les parcours en navigateur servent un build qui n'est pas dans
   * l'image.
   */
  interfaceWeb?: string;
}

/**
@@ -106,10 +112,11 @@ export async function construireServeur(
  const politiqueContenu = [
    "default-src 'self'",
    "script-src 'self'",
    // Les écrans posent leurs marges en attribut (`style={{…}}`), et la feuille
    // de caractères vient de Google Fonts — voir apps/web/index.html.
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "font-src 'self' https://fonts.gstatic.com data:",
    // Les écrans posent leurs marges en attribut (`style={{…}}`). Les polices
    // sont servies par l'instance : plus aucune origine tierce n'est admise
    // pour le style ni pour les caractères — voir apps/web/src/main.tsx.
    "style-src 'self' 'unsafe-inline'",
    "font-src 'self' data:",
    // Les codes d'enrôlement sont rendus en data:, les images de la toile en blob:.
    "img-src 'self' data: blob:",
    "media-src 'self' blob:",
@@ -275,9 +282,23 @@ export async function construireServeur(
  // Un seul artefact, un seul port, une seule origine : le cookie de session
  // n'a donc jamais à franchir de frontière, et l'auto-hébergeur n'a pas de
  // reverse proxy à composer pour que l'application fonctionne.
  const interfaceWeb = join(dirname(fileURLToPath(import.meta.url)), "web");
  const interfaceWeb = options.interfaceWeb
    ?? process.env["KASTELL_WEB_DIR"] ?? join(dirname(fileURLToPath(import.meta.url)), "web");
  if (existsSync(interfaceWeb)) {
    await app.register(statique, { root: interfaceWeb, prefix: "/", wildcard: false });
    await app.register(statique, {
      root: interfaceWeb, prefix: "/", wildcard: false,
      // Brotli, puis gzip, puis le fichier tel quel : la construction les
      // produit tous (EX-12). Sans cela, 1,66 Mo de JavaScript partaient bruts
      // vers un téléphone en 4G.
      preCompressed: true,
      setHeaders(res, chemin) {
        // Un fichier d'« assets » porte son empreinte dans son nom : il ne
        // changera jamais, on le garde un an. « index.html », lui, doit être
        // relu à chaque visite — sinon un déploiement ne se voit pas.
        res.header("cache-control", /[\\/]assets[\\/]/.test(chemin)
          ? "public, max-age=31536000, immutable" : "no-cache");
      },
    });
    app.setNotFoundHandler((req, reply) => {
      // Les chemins d'API inconnus restent des 404 franches ; le reste relève
      // du routeur de l'interface.
+2 −0
Changes for apps/api/src/verif.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ import { verifierLicence } from "./verification/licence.js";
import { verifierInterface } from "./verification/interface.js";
import { verifierAccessibilite } from "./verification/accessibilite.js";
import { verifierRail } from "./verification/rail.js";
import { verifierServiceWeb } from "./verification/service-web.js";
import { verifierMobile } from "./verification/mobile.js";
import { verifierApplicationMobile } from "./verification/application-mobile.js";
import { verifierRelais } from "./verification/relais.js";
@@ -347,6 +348,7 @@ await verifierInterface(rapport);
// ── Accessibilité, mesurée (EX-19) ────────────────────────────────────────
await verifierAccessibilite(rapport);
await verifierRail(rapport);
await verifierServiceWeb(sql, rapport, env);

// ── Appareils mobiles (volet mobile, M0-M1) ───────────────────────────────
await verifierMobile(sql, app, rapport);
+61 −0
Changes for apps/api/src/verification/service-web.ts: 61 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { brotliCompressSync, gzipSync } from "node:zlib";
import type { Sql } from "../db/client.js";
import type { Env } from "../env.js";
import { construireServeur } from "../serveur.js";
import type { Rapport } from "./rapport.js";

/**
 * L'interface telle que l'instance la sert (EX-11, EX-12).
 *
 * Éprouvé en marche : un vrai dossier d'interface, un vrai serveur, de vraies
 * requêtes. Le serveur envoyait 1,66 Mo de JavaScript non compressé, avec un
 * cache à zéro sur des fichiers dont le nom porte l'empreinte — chaque visite
 * retéléchargeait tout. Rien, dans le code, ne le laissait voir : il fallait
 * interroger le serveur.
 */
export async function verifierServiceWeb(sql: Sql, r: Rapport, env: Env): Promise<void> {
  r.titre("L'interface telle qu'on la sert");
  const dossier = await mkdtemp(join(tmpdir(), "kastell-web-"));
  await mkdir(join(dossier, "assets"));
  const script = Buffer.from(`console.log(${JSON.stringify("x".repeat(4000))});`);
  await writeFile(join(dossier, "assets", "index-AbC123.js"), script);
  await writeFile(join(dossier, "assets", "index-AbC123.js.gz"), gzipSync(script));
  await writeFile(join(dossier, "assets", "index-AbC123.js.br"), brotliCompressSync(script));
  await writeFile(join(dossier, "index.html"), "<!doctype html><title>Kastell</title>");

  const app = await construireServeur(env, sql, { silencieux: true, interfaceWeb: dossier });
  try {
    const demander = (url: string, encodages?: string) => app.inject({ method: "GET", url,
      headers: { accept: "text/html,*/*", ...(encodages ? { "accept-encoding": encodages } : {}) } });

    const br = await demander("/assets/index-AbC123.js", "br, gzip");
    r.verifie("EX-12", "un navigateur qui accepte brotli reçoit brotli",
      br.statusCode === 200 && br.headers["content-encoding"] === "br"
        && br.rawPayload.length < script.length / 4,
      `${br.headers["content-encoding"] ?? "aucun encodage"}, ${br.rawPayload.length} octets`);
    const gz = await demander("/assets/index-AbC123.js", "gzip");
    r.verifie("EX-12", "à défaut, gzip",
      gz.headers["content-encoding"] === "gzip", String(gz.headers["content-encoding"]));
    const brut = await demander("/assets/index-AbC123.js");
    r.verifie("EX-12", "et le fichier tel quel à qui n'accepte rien",
      brut.statusCode === 200 && brut.headers["content-encoding"] === undefined
        && brut.rawPayload.length === script.length);
    r.verifie("EX-11", "un fichier à empreinte se garde un an : la visite suivante ne le retélécharge pas",
      /max-age=31536000/.test(String(br.headers["cache-control"]))
        && /immutable/.test(String(br.headers["cache-control"])), String(br.headers["cache-control"]));

    const accueil = await demander("/", "br, gzip");
    r.verifie("EX-11", "la page d'entrée se relit à chaque visite, sans quoi un déploiement ne se voit pas",
      accueil.statusCode === 200 && accueil.headers["cache-control"] === "no-cache",
      String(accueil.headers["cache-control"]));
    const route = await demander("/crises/une-crise/main-courante", "gzip");
    r.verifie("EX-11", "une adresse de l'interface sert la page d'entrée, sans cache non plus",
      route.statusCode === 200 && route.body.includes("<title>Kastell</title>")
        && route.headers["cache-control"] === "no-cache", String(route.headers["cache-control"]));
  } finally {
    await app.close();
  }
}
+0 −6
Changes for apps/web/index.html: 0 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -5,12 +5,6 @@
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <meta name="color-scheme" content="light dark" />
    <title>Kastell</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=JetBrains+Mono:wght@400;500&display=swap"
    />
  </head>
  <body>
    <div id="racine"></div>
Loading