Compiler du Rust vers WebAssembly est devenu la voie de référence pour ajouter du calcul performant dans une application JavaScript existante. Le couple wasm-pack + wasm-bindgen fournit une chaîne reproductible : on écrit du Rust idiomatique, on annote les fonctions à exposer, on lance une commande, et on obtient un paquet npm prêt à importer dans Vite, Webpack ou directement dans une balise <script type="module">.
Ce tutoriel suit la chaîne complète, étape par étape, avec un projet minimaliste (un calcul de Fibonacci puis un parseur JSON simplifié) qui montre comment passer des chaînes, des tableaux et des objets entre Rust et JavaScript sans glue manuelle. Les commandes ont été exécutées avec Rust 1.83, wasm-pack 0.14.0 et wasm-bindgen 0.2.120. Pour le contexte général et le choix entre cette approche et un runtime serveur, on se reportera au guide principal sur WebAssembly en production.
Étape 1 — Préparer l’environnement de compilation
Avant toute chose, il faut une toolchain Rust à jour et le compilateur LLVM associé à la cible WebAssembly. Le canal stable suffit largement : la cible wasm32-unknown-unknown y est supportée Tier 2 depuis longtemps.
On vérifie la version de Rust installée, puis on ajoute la cible WebAssembly. Si rustup n’est pas installé, on suit les instructions de rustup.rs (le projet fournit un script d’installation officiel).
rustup --version
rustup show
rustup target add wasm32-unknown-unknown
Si la cible apparaît dans la sortie de rustup show, c’est que tout est prêt côté Rust. L’avantage de wasm32-unknown-unknown pour l’interop navigateur est qu’elle n’embarque aucune ABI POSIX : le binaire produit est minimal et ne dépend que des fonctions JavaScript que l’on importera.
On installe ensuite wasm-pack. Le binaire est disponible via cargo install, via le script officiel ou via le package npm (sous le capot, le même binaire). On préfère la voie cargo install pour rester dans l’écosystème Rust.
cargo install wasm-pack
wasm-pack --version
La sortie attendue est wasm-pack 0.14.0 ou supérieur. wasm-pack orchestre la compilation (cargo build --target wasm32-unknown-unknown), l’invocation de wasm-bindgen pour générer le binding JavaScript et la production d’un package npm-ready.
Étape 2 — Créer le projet Rust et configurer Cargo
On crée un crate de type bibliothèque. Le mot-clé important est --lib : un binaire (--bin) ne s’exporterait pas comme module WebAssembly. Le nom choisi devient celui du package npm généré.
cargo new --lib wasm-demo
cd wasm-demo
Cargo crée un squelette avec Cargo.toml et src/lib.rs. On édite Cargo.toml pour déclarer le type de bibliothèque cdylib (obligatoire pour produire un fichier .wasm exportable) et ajouter la dépendance wasm-bindgen.
[package]
name = "wasm-demo"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2.120"
La double déclaration ["cdylib", "rlib"] permet à cargo test d’utiliser le code en mode bibliothèque Rust standard, tout en exposant l’export C-compatible nécessaire à WebAssembly. C’est la configuration officielle recommandée par le wasm-bindgen Guide.
Étape 3 — Écrire la première fonction exposée à JavaScript
L’attribut #[wasm_bindgen] est la clé d’interopérabilité. Apposé sur une fonction ou un type Rust, il indique à wasm-bindgen de générer la glue JavaScript correspondante. On commence par une fonction simple, puis on ajoute une variante qui manipule des chaînes.
On édite src/lib.rs pour remplacer son contenu par ce qui suit. La fonction fibonacci calcule le n-ième terme de la suite, la fonction greet compose une chaîne. Les types u32 et String sont traduits automatiquement par wasm-bindgen.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
if n < 2 {
return n as u64;
}
let mut a: u64 = 0;
let mut b: u64 = 1;
for _ in 2..=n {
let next = a + b;
a = b;
b = next;
}
b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Bonjour, {} ! Bienvenue dans WebAssembly.", name)
}
Les annotations #[wasm_bindgen] indiquent à la moulinette de produire des wrappers JavaScript. À la compilation, le binaire .wasm exportera fibonacci et greet, et le fichier JavaScript généré exposera des fonctions homonymes avec leurs signatures TypeScript.
Étape 4 — Compiler avec wasm-pack
On invoque maintenant wasm-pack. Trois cibles sont disponibles selon le contexte de consommation : --target web pour un import direct dans une page HTML, --target bundler pour Vite/Webpack/Rollup, --target nodejs pour un usage côté Node. Pour ce premier essai, on choisit web.
wasm-pack build --target web --release
La sortie attendue affiche les étapes : compilation Cargo en mode release, optimisation du .wasm via wasm-opt (livré avec wasm-pack), génération du package dans pkg/. À la fin, le répertoire pkg/ contient cinq fichiers : wasm_demo.js (loader JavaScript), wasm_demo_bg.wasm (le binaire), wasm_demo.d.ts (les types TypeScript), wasm_demo_bg.wasm.d.ts et un package.json prêt à publier sur npm.
Le passage --release active les optimisations LLVM opt-level=3, puis wasm-opt de Binaryen est appliqué en mode -Os (focus taille) par défaut. Sur la fonction Fibonacci, le binaire généré pèse moins de 20 ko ; sur un parseur complet utilisant serde, on grimpe rapidement à 200-400 ko, ce qui reste raisonnable pour un asset téléchargé une fois.
Étape 5 — Charger le module depuis une page HTML statique
Pour vérifier que tout fonctionne sans bundler, on prépare une page HTML minimale qui importe le module en ES modules natifs. C’est la voie la plus rapide pour démontrer le résultat.
On crée un fichier index.html à la racine du projet (à côté de pkg/) avec le contenu suivant. L’import du loader retourne une fonction init qu’il faut appeler en premier : elle charge et instancie le module .wasm.
<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><title>WASM demo</title></head>
<body>
<pre id="out">chargement...</pre>
<script type="module">
import init, { fibonacci, greet } from './pkg/wasm_demo.js';
await init();
const out = document.getElementById('out');
out.textContent =
greet('Aminata') + '\n' +
'fib(30) = ' + fibonacci(30);
</script>
</body></html>
Comme un module ES ne peut être chargé que par HTTP (pas par file://), on sert le dossier via un serveur statique. Python 3 fait l’affaire sans dépendance.
python3 -m http.server 8080
On ouvre http://localhost:8080/ dans le navigateur : la page doit afficher la salutation et la valeur fib(30) = 832040. Si l’on voit l’erreur « Failed to fetch wasm_demo_bg.wasm », c’est que le chemin relatif est cassé — vérifier la présence du dossier pkg/ à côté de l’HTML.
Étape 6 — Passer des structures complexes via serde
Pour des cas réels, on transmet rarement des entiers seuls. On veut envoyer un objet JavaScript, le manipuler en Rust, retourner une structure typée. wasm-bindgen s’appuie sur serde-wasm-bindgen pour cela.
On ajoute les dépendances serde et serde-wasm-bindgen dans Cargo.toml en respectant les versions récentes (serde 1.0, serde-wasm-bindgen 0.6).
[dependencies]
wasm-bindgen = "0.2.120"
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6"
On ajoute ensuite une fonction qui reçoit un objet, calcule une statistique simple et renvoie un objet typé. Le type JsValue sert de pont opaque entre Rust et JavaScript.
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Order { amount: f64, currency: String }
#[derive(Serialize)]
struct OrderSummary { amount_fcfa: f64, label: String }
#[wasm_bindgen]
pub fn summarize(order: JsValue) -> Result<JsValue, JsValue> {
let o: Order = serde_wasm_bindgen::from_value(order)?;
let rate = match o.currency.as_str() {
"EUR" => 655.957,
"USD" => 600.0,
_ => return Err(JsValue::from_str("devise non gérée")),
};
let s = OrderSummary {
amount_fcfa: o.amount * rate,
label: format!("{} {} => FCFA", o.amount, o.currency),
};
Ok(serde_wasm_bindgen::to_value(&s)?)
}
Côté JavaScript, on appelle summarize({ amount: 12.5, currency: 'EUR' }) et l’on récupère un objet { amount_fcfa: 8194.46, label: '12.5 EUR => FCFA' }. La conversion se fait en deux temps : sérialisation côté JS, désérialisation côté Rust, puis l’inverse au retour.
Étape 7 — Intégrer le module dans un projet Vite ou Webpack
Pour la production, on n’utilise jamais le mode --target web seul : on intègre le package au bundler du projet. Avec Vite, qui est devenu le standard de l’écosystème React/Vue moderne, l’opération est triviale.
On recompile avec la cible bundler.
wasm-pack build --target bundler --release
Dans le projet front-end, on installe le paquet local via un lien npm.
cd /chemin/vers/le/projet-vite
npm install /chemin/vers/wasm-demo/pkg
On l’importe ensuite comme n’importe quel paquet npm. Le top-level await est nécessaire pour attendre l’instanciation du module avant d’exposer les fonctions.
// src/App.tsx
import init, { fibonacci, summarize } from 'wasm-demo';
await init();
console.log('fib(40) =', fibonacci(40));
console.log(summarize({ amount: 100, currency: 'USD' }));
Vite gère automatiquement le découpage en chunks et le streaming du .wasm via WebAssembly.instantiateStreaming. L’expérience est aussi fluide que d’importer du TypeScript.
Étape 8 — Mesurer le gain réel
Avant de migrer une partie significative du code, on quantifie le bénéfice. Sur la suite Fibonacci itérative, le facteur d’accélération vs JavaScript pur est modeste (V8 optimise très bien les boucles entières). Le gain devient massif sur des charges qui mettent V8 en difficulté : décodage d’images, parsing de protocoles binaires, calculs numériques en virgule flottante avec beaucoup d’allocations.
On peut mesurer simplement avec performance.now(). Sur une comparaison parsing JSON volumineux Rust + serde vs JSON.parse natif, le module Rust est rarement plus rapide : JSON.parse est lui-même écrit en C++ optimisé. En revanche, sur du parsing d’un format binaire propriétaire avec validation forte, on observe régulièrement des facteurs 3 à 8.
const t0 = performance.now();
for (let i = 0; i < 1000; i++) fibonacci(35);
const dt = performance.now() - t0;
console.log('Rust wasm:', dt.toFixed(2), 'ms');
La règle pratique est de profiler avant de migrer : on cherche les fonctions qui apparaissent dans le top 10 du flamegraph et qui ont une logique numérique ou binaire. Migrer une fonction d’orchestration courte vers Rust pour la beauté du geste augmente le poids du bundle sans gain perceptible.
Étape 9 — Erreurs fréquentes et leur résolution
Le tableau suivant recense les erreurs rencontrées les plus souvent par les équipes qui démarrent. Chaque ligne donne le symptôme, sa cause et la commande de résolution.
| Symptôme | Cause | Résolution |
|---|---|---|
error[E0463]: can't find crate for std |
Cible wasm32-unknown-unknown manquante |
rustup target add wasm32-unknown-unknown |
wasm-pack: command not found |
Binaire pas dans le PATH après cargo install |
Ajouter ~/.cargo/bin au PATH |
Module charge mais fonctions undefined |
Absence de crate-type = ["cdylib"] |
Éditer Cargo.toml et recompiler |
TypeError: Cannot read .wasm sur Vite |
Mauvais --target (web au lieu de bundler) |
Recompiler avec --target bundler |
Taille du .wasm supérieure à 1 Mo |
Compilation debug ou crates lourds | Ajouter --release et opt-level = "s" |
Erreur MIME application/wasm |
Serveur statique mal configuré | Configurer le MIME ou utiliser un bundler |
Optimiser la taille du binaire en production
Pour un usage navigateur, le poids du .wasm téléchargé impacte directement le Time to Interactive. Trois leviers réduisent significativement la taille du binaire généré.
D’abord, le profil release de Cargo accepte un niveau d’optimisation orienté taille. Dans Cargo.toml, on ajoute un profil [profile.release] dédié.
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
Le couple opt-level = "s" et lto = true demande à LLVM de privilégier la taille avec link-time optimization. panic = "abort" retire le code de unwinding (inutile en WebAssembly côté navigateur, où l’on n’attrape jamais un panic Rust). Sur le projet de démonstration, ces réglages font passer le binaire de 92 ko à 38 ko.
Ensuite, wasm-pack invoque wasm-opt de Binaryen avec -Os par défaut. Pour pousser plus loin, on peut forcer -Oz (priorité taille maximale) via la variable WASM_OPT_FLAGS="-Oz". Enfin, on évite les dépendances qui tirent tokio ou async-std côté navigateur — elles n’apportent rien sans WASI et alourdissent inutilement le bundle.
Aller plus loin avec WebAssembly Rust
Une fois la chaîne maîtrisée côté navigateur, deux directions s’ouvrent. La première consiste à utiliser le même module Rust côté serveur — le tutoriel WASI et serveurs WebAssembly avec Wasmtime détaille cette voie en repartant d’un projet vide vers une cible wasm32-wasip2. La seconde consiste à déployer ce code à la périphérie : le tutoriel Déployer un module WebAssembly Rust sur Cloudflare Workers repart d’un crate similaire et le pousse en production sur l’edge.
Pour les enjeux sécurité — isolation d’un agent IA, exécution de code utilisateur dans une sandbox — le tutoriel Isolation d’un agent IA dans une sandbox WebAssembly approfondit le modèle de capacités du composant WASI 0.2.
Ressources officielles
- The wasm-bindgen Guide — documentation de référence des annotations
- The wasm-pack Book — chaîne de build complète
- github.com/wasm-bindgen/wasm-bindgen — code source et changelog
- github.com/rustwasm/wasm-pack/releases — releases
wasm-pack - rustc — wasm32-unknown-unknown — fiche officielle de la cible
- serde-wasm-bindgen sur crates.io — sérialisation typée
La chaîne wasm-pack + wasm-bindgen permet d’ajouter du calcul performant à une application JavaScript en quelques commandes. Le coût initial (toolchain, première compilation) est compensé dès que l’on cible des fonctions qui pèsent réellement dans le profilage. Pour des architectures côté serveur, on bascule sur la cible wasm32-wasip2 et un runtime tel que Wasmtime.