Comment VaultTools tourne dans le navigateur (Rust + WASM)
Regarder le résumé vidéo
45 outils. Pas de backend. Pas de Lambda. Pas de S3.
Vault-Tools est une suite de plus de 45 outils de traitement de fichiers (images, PDF, texte, fichiers, utilitaires développeur) qui fonctionnent entièrement dans le navigateur. Pas de serveur de traitement, pas de file d’attente, pas de stockage temporaire. Chaque octet est traité localement, grâce à du Rust compilé en WebAssembly.
Cet article détaille l’architecture technique du projet : pourquoi Rust, comment le code est organisé, comment le Wasm est chargé dans Astro, et quels problèmes concrets j’ai dû résoudre en cours de route.
Pourquoi Rust
Le choix de Rust n’est pas un caprice technologique. Trois raisons concrètes ont guidé cette décision.
La sécurité mémoire. En WebAssembly, un buffer overflow ne produit pas un crash élégant ; il produit un comportement indéfini silencieux. Le modèle d’ownership de Rust élimine des catégories entières de bugs mémoire à la compilation. Pour des outils qui manipulent des fichiers utilisateur de taille arbitraire, c’est non négociable.
L’écosystème de crates. Rust dispose de bibliothèques matures pour la manipulation d’images (image, kamadak-exif), de PDF (lopdf, pdf-writer), de texte (serde_json, pulldown-cmark, csv), et bien d’autres. La plupart compilent vers wasm32-unknown-unknown sans modification.
La compacité du Wasm généré. Contrairement à des langages avec garbage collector (Go, par exemple, qui embarque un runtime de plusieurs mégaoctets), Rust produit des binaires WebAssembly compacts. Avec wasm-opt et un choix judicieux de dépendances, chaque module reste dans une taille raisonnable pour un chargement web.
L’architecture à 5 crates
Le code Rust est organisé en 5 crates, chacune correspondant à une catégorie d’outils :
dev-tools: hachage (SHA-1, SHA-256), conversion de couleurs, encodage URL, génération et lecture de QR codes, UUID, décodage JWT, parsing cron, lorem ipsum, conversion de timestamps.image-tools: conversion de formats, compression, redimensionnement, lecture/suppression EXIF, image vers Base64, génération de favicons, mockups, extraction de palette, rastérisation SVG, transformations (rotation, flip, crop), encodage HEIC, watermark textuel viaresvg.text-tools: formatage et validation JSON, conversion CSV/JSON, Base64, Markdown vers HTML, conversion YAML/JSON/TOML, XML vers JSON, entités HTML, compteur de mots, diff de texte, HTML vers Markdown, testeur regex.pdf-tools: fusion, découpage, réorganisation de pages, images vers PDF, édition de métadonnées, compression, PDF vers images, numérotation de pages, watermark via annotation.file-tools: calcul de checksums (MD5, SHA), conversion TSV/CSV, visualiseur CSV, compression/extraction ZIP.
Chaque crate suit la même convention : un module par outil (src/hash.rs, src/color.rs, etc.) et un lib.rs qui ne fait que déclarer les modules et réexporter les fonctions publiques.
La chaîne de compilation
Le build Wasm est géré par un script shell (scripts/build-wasm.sh) qui itère sur les 5 crates :
for crate in dev-tools image-tools text-tools pdf-tools file-tools; do
wasm-pack build "crates/$crate" --target web --out-dir "../../site/public/wasm/$crate"
done
wasm-pack appelle cargo build --target wasm32-unknown-unknown en interne, puis exécute wasm-bindgen pour générer les bindings JavaScript. Le résultat est un fichier .wasm et un fichier .js (le “glue code”) par crate, déposés directement dans site/public/wasm/.
Point important : les binaires Wasm sont commités dans le dépôt Git. Le serveur de build Cloudflare n’a pas de toolchain Rust, donc le build de production n’exécute que la partie Astro.
Charger le Wasm dans Astro
Astro utilise Vite, qui gère les modules ES de manière standard. Mais le chargement dynamique de WebAssembly nécessite une approche spécifique.
Les scripts des pages outils utilisent <script is:inline> (et non des module scripts classiques) car ils font un import() dynamique du Wasm au runtime :
const wasm = await import('/wasm/pdf-tools/pdf_tools.js');
await wasm.default();
// Le module est prêt, on peut appeler les fonctions
const result = wasm.compress_pdf(pdfBytes, quality);
La raison du is:inline : Astro/Vite bundlerait normalement les imports, mais l’import dynamique du fichier .js généré par wasm-pack doit rester tel quel pour que le chargement du .wasm associé fonctionne correctement au runtime.
L’appel wasm.default() correspond à la fonction init() exportée par wasm-bindgen. Elle charge et instancie le binaire .wasm.
Le pipeline de traitement
Le flux de données est identique pour tous les outils basés sur des fichiers :
Fichier utilisateur
→ FileReader.readAsArrayBuffer()
→ new Uint8Array(buffer)
→ fonction Wasm (reçoit &[u8], retourne Vec<u8>)
→ new Blob([result])
→ URL.createObjectURL(blob)
→ <a download>.click()
Côté Rust, les fonctions reçoivent un &[u8] (les octets bruts du fichier) et retournent un Vec<u8> (le fichier transformé). wasm-bindgen se charge de la conversion entre Uint8Array JavaScript et &[u8] / Vec<u8> Rust.
Le résultat ne repasse jamais par le réseau. Le Blob est créé en mémoire, l’ObjectURL pointe vers cette mémoire locale, et le clic sur le lien de téléchargement déclenche la sauvegarde sur le disque.
Les problèmes résolus
Construire 45 outils Wasm n’est pas un long fleuve tranquille. Voici les principaux défis techniques et leurs solutions.
Le builder pattern pour les opérations multi-fichiers
WebAssembly ne peut pas recevoir un Vec<Uint8Array> directement. Pour les outils comme la fusion de PDF ou la conversion images-vers-PDF, j’utilise un builder pattern côté Rust :
#[wasm_bindgen]
pub struct PdfMerger { pages: Vec<Vec<u8>> }
#[wasm_bindgen]
impl PdfMerger {
#[wasm_bindgen(constructor)]
pub fn new() -> Self { Self { pages: vec![] } }
pub fn add_pdf(&mut self, data: &[u8]) { self.pages.push(data.to_vec()); }
pub fn merge(&self) -> Result<Vec<u8>, JsValue> { /* ... */ }
}
Côté JavaScript, on instancie le builder, on ajoute les fichiers un par un, puis on appelle la méthode finale.
lopdf épinglé à la version 0.35
La crate lopdf (manipulation de PDF bas niveau) ne compile plus vers wasm32 à partir de la version 0.36. Elle est épinglée à =0.35 dans le Cargo.toml. C’est une contrainte dure : toute mise à jour doit être testée contre la cible Wasm.
Le double requestAnimationFrame
Un problème subtil d’UX : quand l’utilisateur clique sur un bouton, on veut afficher un spinner avant de lancer le traitement Wasm (qui est synchrone et bloque le thread principal). Un seul requestAnimationFrame ne suffit pas, car le navigateur n’a pas encore composité le frame.
La solution :
button.classList.add('btn-spinner');
await new Promise(function(r) {
requestAnimationFrame(function() {
requestAnimationFrame(r);
});
});
// Maintenant le spinner est visible, on peut lancer le Wasm
const result = wasm.compress_pdf(bytes, quality);
Le double rAF garantit que le navigateur a peint le frame avec le spinner avant que le code synchrone Wasm ne prenne le contrôle du thread.
Sous-ensemble de polices pour le watermark
L’outil de watermark textuel sur les images utilise resvg pour rasteriser du SVG contenant du texte. Cela nécessite une police embarquée dans le binaire Wasm via include_bytes!. Une police Inter Regular complète pèse ~400 Ko ; en la réduisant aux glyphes ASCII avec pyftsubset, on tombe à 26 Ko.
const FONT_DATA: &[u8] = include_bytes!("fonts/inter-ascii.ttf");
Le pattern hybride JS + Wasm
Certains formats n’ont pas de décodeur Rust pur (HEIC, par exemple). La solution est un pattern hybride : un package npm pour le décodage dans un <script> bundlé qui expose le résultat via window.__heicDecoder, puis un <script is:inline> qui passe les pixels décodés au module Wasm pour l’encodage final.
Deux blocs <script> sont nécessaires parce que is:inline ne peut pas importer de packages npm, et les scripts bundlés ne peuvent pas faire d’import() dynamique de Wasm.
Performances
Quelques données concrètes sur les performances de cette architecture :
Taille des binaires. Chaque crate produit un module Wasm indépendant. Seul le module nécessaire est chargé pour un outil donné, pas de bundle monolithique. Le code splitting est naturel grâce à la séparation en 5 crates.
Vitesse de traitement. WebAssembly s’exécute à des vitesses proches du natif. La compression d’un PDF de 10 Mo prend typiquement moins d’une seconde. La conversion d’une image de 5 Mo est quasi instantanée. Pas de latence réseau, pas de file d’attente, juste du calcul pur.
Chargement initial. Le module Wasm est chargé au premier accès à un outil de la catégorie. Les accès suivants bénéficient du cache navigateur. Sur une connexion standard, le chargement initial est imperceptible.
Conclusion
L’architecture de Vault-Tools prouve qu’on peut construire des outils de traitement de fichiers complets, performants et respectueux de la vie privée sans aucun backend.
Rust et WebAssembly ne sont pas les technologies les plus simples à mettre en oeuvre. Mais elles offrent une garantie architecturale que ni les promesses marketing ni les conditions d’utilisation ne peuvent égaler : si le code ne fait aucune requête réseau, il est physiquement impossible que vos fichiers quittent votre machine.
Vous ne me croyez pas ? Ouvrez l’onglet Network de vos DevTools et essayez n’importe quel outil. Le code source est public. Vérifiez par vous-même.