speedster-js Guide

Routeur

Les routes viennent de l'arborescence des fichiers. Les dépendances viennent de modules. Le point où les deux se rencontrent est le chargement d'une route.

src/routes/
  +layout.spd              englobe tout ce qui est en dessous
  +page.spd                /
  a-propos/+page.spd       /a-propos
  taches/
    +layout.spd            gabarit imbriqué
    +page.spd              /taches
    +page.ts              load({ params, resolve, signal })
    [id]/+page.spd         /taches/:id
  fichiers/
    [...chemin]/+page.spd  /fichiers/n/importe/quoi

Rien à déclarer ailleurs : un dossier avec un +page.spd est une route.


Mise en place

speedster init amorce tout ce qui suit. Dans un projet existant :

// src/main.ts
import { mount } from '@ajustor/speedster'
import { createContainer, createRouter } from '@ajustor/speedster/router'
import { application } from './app/module.ts'
import { routes } from './routes.generated.ts'

const container = createContainer(application)

mount(createRouter({ routes, container }), {
  target: document.getElementById('app')!,
})

routes.generated.ts est produit par la découverte :

bun run routes        # une fois — c'est `speedster routes`
bun run dev           # et à chaque route ajoutée

La table est écrite dans un module, et non parcourue à l'exécution. Ce n'est pas une optimisation : c'est ce qui permet à l'empaqueteur de voir les imports — donc de découper et d'élaguer — et au compilateur de vérifier que chaque page existe.


Le découpage

Chaque page est un morceau à part. La table l'importe dynamiquement, donc l'empaqueteur en fait un fichier séparé que le navigateur ne télécharge qu'au moment d'y aller :

// src/routes.generated.ts — généré
import Gabarit_layout from './routes/+layout.spd'          // sur toutes les routes

const Gabarit_taches = lazy(() => import('./routes/taches/+layout.spd'))

export const routes = defineRoutes([
  { pattern: '/', layouts: [Gabarit_layout], page: lazy(() => import('./routes/+page.spd')) },
  {
    pattern: '/taches',
    layouts: [Gabarit_layout, Gabarit_taches],
    page: lazy(() => import('./routes/taches/+page.spd')),
    load: lazyLoad(() => import('./routes/taches/+page.ts')),
  },
])

Trois décisions y sont prises, et chacune a une raison qui se mesure.

Un gabarit présent sur toutes les routes reste statique. Il sera chargé quoi qu'il arrive : le découper n'économise rien et ajoute un aller-retour avant le premier affichage.

Un gabarit partiel est découpé, et nommé. Découpé parce qu'une route qui ne l'utilise pas n'a pas à le payer ; nommé parce que plusieurs routes le partagent et qu'un module mémoïsé une seule fois évite de l'attendre deux fois.

Un load est découpé aussi. Son module ne sert qu'à sa route.

Le build doit suivre — c'est une ligne du build.ts amorcé :

await Bun.build({
  entrypoints: ['src/main.ts'],
  splitting: true,                                   // sans quoi tout revient dans un fichier
  naming: { chunk: 'morceaux/[name]-[hash].[ext]' }, // empreinte : cache indéfini
})

Ce que ça coûte, ce que ça gagne

bun run size mesure le premier chargement — la fermeture des imports statiques depuis la page, et non le seul morceau d'entrée, qui serait un chiffre flatteur :

minifié gzip
6 pages, table statique 20,99 ko 8,10 ko
6 pages, découpées 16,92 ko 6,96 ko
une page lourde, statique 36,58 ko 10,33 ko
une page lourde, découpée 15,54 ko 6,33 ko

Six pages ordinaires : −14 %. Dès qu'une page traîne une dépendance lourde — un éditeur, un référentiel, une bibliothèque de graphiques — : −39 %, et l'écart croît avec la taille de cette page, puisqu'elle cesse d'être dans le chemin de tous les autres.

Le découpage n'est pas gratuit pour autant. Gzip compresse moins bien six petits fichiers qu'un gros, et un morceau demande un aller-retour de plus. Sur une application d'une ou deux pages minuscules, la table statique gagne :

bunx speedster routes --eager

Servir une application découpée

Deux réglages, et le second n'est pas propre au découpage.

Les morceaux se mettent en cache indéfiniment. Leur nom porte une empreinte du contenu : morceaux/+page-zq4fqwmm.js ne changera jamais de contenu, et un contenu qui change produit un autre nom. main.js et index.html, eux, gardent un nom fixe et ne doivent donc pas être mis en cache longuement.

Une adresse de route n'est pas un fichier. Le routeur vit dans le navigateur : /taches doit rendre index.html, qui lira l'URL et montera la bonne route. Sans ce repli, un lien partagé ou un simple F5 tombe sur un 404. Le preview.ts amorcé le fait, et tout hébergeur statique le propose sous un nom ou un autre — SPA fallback, rewrite, try_files. Ce qui ressemble à un fichier — une extension — doit continuer à répondre 404, sinon un favicon absent reçoit du HTML.

La page annonce au passage les morceaux que l'entrée importe :

<link rel="modulepreload" href="/morceaux/main-a9qnasx6.js">

build.ts les injecte, et c'est mesurable : sans eux, le navigateur ne les découvre qu'après avoir analysé main.js — un niveau de plus dans la cascade. Avec, les cinq morceaux partagés partent avant main.js, et seule la page de la route arrive après.

Le préchargement, pour que ça ne se voie pas

Un morceau téléchargé au clic se sent. Le routeur le récupère donc au survol et à la mise au point d'un lien interne, ce qui laisse en général le temps qu'il arrive :

createRouter({ routes, preload: 'none' })   // pour désactiver
useRouter().preload('/taches/12')            // à la main

Le load, lui, n'est pas appelé au survol — seulement son module. La distinction est le point délicat : télécharger du code est sans effet de bord, appeler un chargement ne l'est pas. Il peut écrire, compter, facturer, et un survol n'est pas une intention ferme.

Une route déjà téléchargée est montée sans attendre : un retour en arrière ne scintille pas, puisqu'il n'y a plus rien à demander.


Les segments

Dossier Motif Capture
taches /taches —
[id] /:id un segment, dans params.id
[...chemin] tout ce qui suit le reste, éventuellement vide

Le littéral gagne sur le paramètre, qui gagne sur la capture finale. Sans cette règle, /taches/[id] intercepterait /taches/nouvelle : une page vide, sans erreur, et aucun moyen d'y remédier autrement qu'en renommant ses dossiers.

Une capture finale accepte le vide : /fichiers/[...chemin] apparie /fichiers, sinon il faudrait déclarer deux routes pour un seul cas.

Un paramètre arrive décodé : /taches/caf%C3%A9 donne café.


Les gabarits

Un +layout.spd englobe son dossier et tout ce qui est en dessous. Il reçoit la suite de la chaîne en children :

<!-- src/routes/+layout.spd -->
<script lang="ts">
  import type { Snippet } from '@ajustor/speedster'
  import { useRouter } from '@ajustor/speedster/router'

  export let children: Snippet

  const router = useRouter()
</script>

<nav>
  <a href="/">Accueil</a>
  <a href="/taches">Tâches</a>

  <span role="status" aria-live="polite" hidden={router.navigating() === null}>…</span>
</nav>

<main>{@render children()}</main>

La région d'état est masquée par un attribut, et non montée par un {#if} : une région aria-live doit exister avant de changer, sinon un lecteur d'écran n'annonce rien.

Un gabarit qui n'englobe aucune page est signalé à la régénération — sans quoi on cherche longtemps pourquoi une mise en page reste invisible.


Le chargement

Un +page.ts à côté d'une page exporte load. Son retour devient la propriété data de la page.

// src/routes/taches/+page.ts
import { Taches } from '../../app/taches.ts'
import type { LoadContext } from '@ajustor/speedster/router'

export function load({ resolve, params, signal }: LoadContext) {
  return { taches: resolve(Taches).toutes() }
}
<!-- src/routes/taches/+page.spd -->
<script lang="ts">
  export let data: { taches: readonly Tache[] }
</script>

<ul>{#each data.taches as tache (tache.id)}<li>{tache.titre}</li>{/each}</ul>

load peut être asynchrone. signal s'abandonne quand une navigation plus récente survient : passez-le à fetch pour ne pas garder en vol une requête dont le résultat ne sera plus affiché.

Deux états, et c'est la seule chose à comprendre

Le routeur distingue ce que l'URL demande de ce qui est monté. Les confondre est le défaut classique d'un routeur qui charge des données : la page suivante s'affiche vide en attendant les siennes, puis se remplit.

Ici la page précédente reste à l'écran jusqu'à ce que le chargement ait abouti. navigating() rend l'adresse en cours, ou null au repos — de quoi montrer une progression. Et un jeton départage les navigations : la dernière demandée gagne, quelle que soit celle qui répond la première.

Une page n'est remontée que si la route change. Passer de /taches/1 à /taches/2 la garde en place : ses propriétés sont lues à travers des accesseurs, comme n'importe quelle propriété du framework.


Les liens internes sont interceptés : un <a href="/taches"> suffit. Tout le reste est laissé au navigateur — cible externe, download, nouvel onglet, clic avec modificateur, ancre pure. Intercepter au-delà casserait des attentes que personne ne pense à vérifier.

const router = useRouter()

router.navigate('/taches/12')
router.navigate('/taches', { replace: true })

router.location()   // { path, search, hash }
router.params()     // { id: '12' }
router.pattern()    // '/taches/[id]', ou null
router.navigating() // adresse en cours, ou null

useRouter() lève hors d'un routeur : mieux vaut le dire que rendre un état inerte que l'appelant croira vivant.


Les fournisseurs

L'organisation est celle de NestJS — des services groupés en modules, résolus par inversion de dépendance — sans réflexion.

// src/app/taches.ts
import { injectable, token } from '@ajustor/speedster/router'

export const MAINTENANT = token<() => number>('maintenant')

@injectable(MAINTENANT)
export class Taches {
  constructor(private readonly maintenant: () => number) {}
}
// src/app/module.ts
import { module, value } from '@ajustor/speedster/router'
import { MAINTENANT, Taches } from './taches.ts'

export const application = module({
  name: 'application',
  providers: [value(MAINTENANT, () => Date.now()), Taches],
})

Une classe annotée est sa propre clé : resolve(Taches) rend l'instance, et il n'y a pas de jeton à inventer pour ce qui en a déjà un. Un jeton reste nécessaire pour ce qui n'est pas une classe — une fonction, une valeur de configuration.

Pourquoi pas les décorateurs de NestJS

Ceux de NestJS reposent sur emitDecoratorMetadata et reflect-metadata : les types du constructeur sont lus à l'exécution. Ceux-ci sont les décorateurs standard de TC39, natifs dans TypeScript 5 et dans Bun, et n'enregistrent qu'une liste — celle qui est écrite.

Le prix est de nommer les dépendances au lieu de les déduire. Le gain n'est pas seulement l'absence de réflexion : la liste est vérifiée. Le décorateur exige que le constructeur accepte exactement ce que les clés résolvent.

@injectable(NOM, MAINTENANT)          // inversées
export class Taches {
  constructor(private readonly maintenant: () => number, private readonly nom: string) {}
}
// → TS1238 : Type 'string' is not assignable to type '() => number'

NestJS, qui déduit, ne s'en aperçoit qu'au démarrage.

Ce qu'un conteneur doit surtout faire : échouer clairement

Faute Message
Jeton fourni deux fois nomme les deux modules
Jeton absent nomme le jeton et par où le déclarer
Cycle nomme la boucle : a → b → a
Classe en fournisseur sans @injectable la nomme, plutôt que de la construire à vide

Une valeur est construite à sa première résolution, puis mémorisée : un module dont rien n'est lu ne coûte que sa déclaration, ce qui permet d'en importer largement sans y penser. La libération se fait à l'envers de la construction — un service libéré peut encore avoir besoin de ceux qu'il a demandés, jamais de ceux qui l'ont demandé.

Pour un test, overrides remplace un fournisseur sans toucher aux modules :

createContainer(application, { overrides: [value(MAINTENANT, () => 0)] })

Générer

bunx speedster new route taches/[id] --load
bunx speedster new layout taches
bunx speedster new service journal

Une route et un gabarit sont désignés par un chemin, pas par un nom : taches/[id] est pris tel quel, le normaliser détruirait la capture. / désigne la racine.

La table, elle, se régénère sans rien générer d'autre :

bunx speedster routes

Elle est écrite par writeRoutes, que la commande, le build.ts du projet et son serveur de développement appellent tous les trois : un module écrit ailleurs, ou nommé autrement, ne serait plus celui qu'importe le point d'entrée.


Le poids

minifié gzip
routeur seul 9,6 ko 3,8 ko
routeur et injection 12,2 ko 4,8 ko

Le conteneur s'élague séparément : une application qui n'injecte rien ne le paie pas. bun run size les mesure à chaque fois.

Ces chiffres portent le chargement à la demande et le préchargement — 0,38 ko compressés —, y compris pour une application qui n'en fait pas usage : la décision étant prise à l'exécution, l'empaqueteur ne peut pas l'élaguer. C'est le prix de la symétrie entre les deux formes d'une table.


Ce que le routeur ne fait pas

Aucun rendu serveur. Il lit location et écrit dans l'historique. C'est un périmètre, pas un oubli : le rendu serveur existe pour les composants (Rendu serveur), mais le routeur n'y participe pas encore.

Le premier affichage demande un aller-retour de plus. La page d'arrivée est un morceau, et rien ne sait lequel avant de lire l'URL. Un rendu serveur pourrait l'annoncer dans la page — c'est ce que fait SvelteKit —, mais le routeur n'en fait pas.

Il remonte la chaîne entière des gabarits quand on passe d'une route à l'autre. Un gabarit partagé entre deux routes est donc reconstruit à la navigation. Le garder en place demanderait de comparer les chaînes et de ne remonter que la partie qui diffère.

La table ne vérifie pas les propriétés. Le routeur ne peut pas savoir que le retour d'un load correspond au data déclaré par sa page : cette relation n'existe que dans l'arborescence des fichiers. La vérifier demanderait de générer un type par route.