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.
Naviguer
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.