Aller au contenu

Rust et WebAssembly

WebAssembly (souvent abrégé en Wasm) permet d’exécuter du code écrit dans des langages comme Rust à l’intérieur d’un navigateur web, à côté de JavaScript. Voyez-le comme un second moteur dans le navigateur : JavaScript pilote toujours la page, mais quand il y a un travail lourd (filtres d’image, physique, analyse de gros fichiers), vous pouvez le confier à du code Rust compilé et rapide.

Sur cette page, vous allez écrire une toute petite bibliothèque Rust, la compiler en Wasm et l’appeler depuis une page web. Rien de tout cela ne tourne dans le Playground Rust (il faut un navigateur et des outils supplémentaires) : la plupart des exemples sont donc de simples blocs de code à essayer sur votre machine. Les parties en Rust pur, en revanche, vous pouvez les tester tout de suite.

Qu’est-ce que WebAssembly, et pourquoi Rust s’y prête si bien

Section intitulée « Qu’est-ce que WebAssembly, et pourquoi Rust s’y prête si bien »

WebAssembly est un format binaire compact pour les programmes. Tous les grands navigateurs savent l’exécuter, à une vitesse proche du natif, dans le même bac à sable de sécurité que JavaScript. Et ce n’est pas réservé aux navigateurs : des serveurs et des plateformes en périphérie de réseau (edge) exécutent aussi du Wasm.

Rust est l’un des langages les plus populaires pour Wasm, pour plusieurs raisons :

  • Pas de ramasse-miettes (garbage collector) ni de gros runtime. Rust n’a pas besoin d’embarquer de mécanique supplémentaire avec votre code, donc les fichiers .wasm peuvent rester petits.
  • D’excellents outils. wasm-bindgen écrit pour vous le code de liaison entre Rust et JavaScript, et wasm-pack construit le tout en une seule commande.
  • Sûreté et vitesse. Vous gardez la même sécurité mémoire et les mêmes performances que sur un ordinateur de bureau.

D’abord, indiquez à Rust qu’il peut compiler pour la cible (target) Wasm. Une cible, c’est « le type de machine sur lequel le code va tourner » ; wasm32-unknown-unknown signifie « Wasm 32 bits, sans système d’exploitation particulier » :

Terminal window
rustup target add wasm32-unknown-unknown

Ensuite, installez wasm-pack. Il compile votre crate, lance wasm-bindgen et range tout ce dont vous avez besoin dans un seul dossier. Utilisez l’installeur officiel, ou compilez-le avec Cargo :

Terminal window
# Installeur Linux / macOS, depuis le site de wasm-pack
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# ou, sur n'importe quel système où Rust est installé
cargo install wasm-pack --locked

Un module Wasm est une bibliothèque, pas un programme avec un main. Créez-en une :

Terminal window
cargo new --lib hello-wasm
cd hello-wasm

Puis modifiez Cargo.toml. La ligne crate-type demande à Cargo de produire une bibliothèque adaptée à .wasm (cdylib), et wasm-bindgen est la crate qui relie Rust et JavaScript :

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"

Écrivez maintenant deux fonctions dans src/lib.rs. L’attribut #[wasm_bindgen] marque chacune d’elles comme « appelable depuis JavaScript » :

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Bonjour, {name} ! Cette phrase a été construite en Rust.")
}

C’est tout le Rust dont vous avez besoin. Remarquez que ce sont des fonctions ordinaires : wasm-bindgen se charge de transformer une chaîne JavaScript en &str, et la String renvoyée de nouveau en chaîne JavaScript.

Voici une habitude qui fait gagner beaucoup de temps : gardez le vrai travail dans des fonctions Rust ordinaires, et testez-les sur votre ordinateur avec cargo test ou un petit main. Déboguer dans un navigateur est plus lent que dans un terminal. La même logique, sans l’attribut, tourne partout :

fn add(a: i32, b: i32) -> i32 {
a + b
}
fn greet(name: &str) -> String {
format!("Bonjour, {name} ! Cette phrase a été construite en Rust.")
}
fn main() {
println!("{}", greet("Ferris"));
println!("2 + 3 = {}", add(2, 3));
}

Sortie :

Bonjour, Ferris ! Cette phrase a été construite en Rust.
2 + 3 = 5
▶ Essayez par vous-même

Une fois la logique correcte, l’enveloppe #[wasm_bindgen] n’est qu’une mince porte vers le navigateur. (Les tests marqués #[test] tournent aussi en natif avec cargo test : voir Les tests.)

Depuis le dossier de la crate, lancez :

Terminal window
wasm-pack build --target web

--target web signifie « produire des fichiers qu’un navigateur peut charger directement avec une balise <script type="module"> », sans avoir besoin d’un bundler comme webpack. Chez nous, le nouveau dossier pkg/ contenait :

pkg/
├── hello_wasm_bg.wasm le code Rust compilé
├── hello_wasm.js le code de liaison JavaScript qui le charge et enveloppe chaque fonction
├── hello_wasm.d.ts les types TypeScript de vos fonctions
├── hello_wasm_bg.wasm.d.ts
└── package.json

Le tiret de hello-wasm est devenu un tiret bas dans les noms de fichiers. Par défaut, wasm-pack build fait un build release (optimisé) ; ajoutez --dev pour obtenir un build plus rapide et non optimisé pendant vos essais.

Créez index.html à côté de Cargo.toml (pour que pkg/ se trouve juste à côté) :

<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Bonjour depuis Rust</title>
</head>
<body>
<p id="message">Chargement…</p>
<script type="module">
import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // télécharge et démarre le fichier .wasm
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

L’export par défaut, init(), télécharge le fichier .wasm et le démarre. Ensuite, add et greet sont des fonctions JavaScript normales.

Les navigateurs refusent de charger des modules depuis une adresse file:// : il vous faut donc un petit serveur web local. Si Python est installé, voici le plus rapide :

Terminal window
python3 -m http.server 8080

Ouvrez http://localhost:8080 dans votre navigateur. Chez nous, la page affichait :

Bonjour, Ferris ! Cette phrase a été construite en Rust. 2 + 3 = 5

Cette phrase a été construite par Rust, en train de tourner dans le navigateur. 🦀

Ce qui traverse la frontière à bas prix (et ce qui coûte)

Section intitulée « Ce qui traverse la frontière à bas prix (et ce qui coûte) »

Rust et JavaScript ne partagent pas la mémoire de la même façon. Le module Wasm possède son propre bloc de mémoire, et les valeurs doivent voyager d’un côté à l’autre. Certains trajets sont gratuits, d’autres coûtent une copie :

Type en Rust En JavaScript Coût
i32, u32, f64, bool number, boolean Faible : passé directement
&str, String string Une copie, plus une conversion UTF-16 vers UTF-8 (ou l’inverse)
&[u8], Vec<u8> Uint8Array Une copie vers (ou depuis) la mémoire Wasm
&mut [u8] Uint8Array Copié à l’entrée, puis recopié après l’appel

La règle d’or est donc : faire peu d’appels, mais qui font chacun beaucoup de travail. Appeler Rust une fois pour traiter toute une image est rapide. Appeler Rust un million de fois, une fois par pixel, revient à passer l’essentiel du temps à franchir la frontière.

Voici à quoi cela ressemble. Cette fonction éclaircit tout un tampon de valeurs de pixels en un seul appel :

use wasm_bindgen::prelude::*;
/// Rend chaque pixel plus clair. JS passe un Uint8Array.
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

Côté JavaScript, après await init(), vous appelleriez brighten(pixels, 20) avec un Uint8Array, et le tableau serait modifié sur place. La boucle elle-même est du Rust pur, vous pouvez donc la tester en natif :

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add s'arrête à 255 au lieu de déborder
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

Sortie :

[30, 120, 255]
▶ Essayez par vous-même

Remarquez que 250 + 20 a donné 255, et non un plantage ou un retour à zéro : saturating_add s’arrête à la valeur maximale. C’est exactement ce qu’il faut pour des couleurs.

Chaque octet de votre fichier .wasm doit être téléchargé avant que la page puisse s’en servir. Quelques conseils :

  • Livrez toujours le build release. Pour notre petit exemple, wasm-pack build --dev a produit un fichier .wasm de 58 603 octets ; le build release par défaut faisait 21 546 octets. (Mesuré sur notre machine avec wasm-opt désactivé : vos chiffres seront différents.)
  • Laissez wasm-pack lancer wasm-opt. En mode release, wasm-pack passe par défaut le résultat dans l’optimiseur wasm-opt de Binaryen, ce qui le réduit généralement encore.
  • Activez la compression sur votre serveur. Notre fichier de 21 546 octets ne pesait plus que 8 479 octets après gzip.
  • Essayez des réglages orientés taille, puis mesurez :
[profile.release]
opt-level = "z" # optimiser la taille ("s" est une version plus douce)
lto = true
codegen-units = 1

Soyez honnête avec vous-même et mesurez. Pour notre exemple à deux fonctions, ces réglages n’ont pas aidé : le fichier est ressorti à 22 833 octets, un peu plus gros. Ils sont en général plus rentables sur de grosses crates. Des choses comme format! et les messages de panic ajoutent aussi du code : une crate qui les évite peut être beaucoup plus petite.

Quelques exemples, chacun avec l’article de l’entreprise concernée (en anglais) :

Oublier crate-type = ["cdylib"]. Sans cette ligne, wasm-pack s’arrête tout de suite. Heureusement, le message d’erreur (en anglais) vous dit exactement quoi ajouter :

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

Ouvrir index.html par un double-clic. La page reste bloquée sur « Chargement… », et la console du navigateur (dans Chrome) affiche :

Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.

Autrement dit, la politique CORS bloque le script parce qu’il est chargé depuis file://. La solution : servez le dossier avec un serveur web local, comme python3 -m http.server, et ouvrez plutôt l’adresse http://localhost.

Appeler une fonction avant await init(). Le module Wasm n’est pas encore chargé, et le code de liaison n’a donc rien à appeler. Quand nous avons supprimé la ligne init(), Chrome a affiché Cannot read properties of undefined (reading '__wbindgen_free'). Faites toujours await init() en premier.

Et ensuite : des applications web complètes en Rust

Section intitulée « Et ensuite : des applications web complètes en Rust »

Appeler quelques fonctions Rust depuis JavaScript est un excellent début. Si vous voulez construire toute l’application web en Rust, regardez ces frameworks. Tous deux compilent en Wasm et vous permettent d’écrire des composants en Rust, un peu comme avec React :

  • Leptos (en anglais) : un framework rapide et moderne, qui sait aussi générer les pages côté serveur.
  • Yew (en anglais) : un framework mûr, basé sur des composants.
  • WebAssembly est un format binaire rapide et sûr que les navigateurs (et les serveurs) savent exécuter ; Rust s’y compile très bien car il n’a pas de ramasse-miettes.
  • Ajoutez la cible avec rustup target add wasm32-unknown-unknown, et installez wasm-pack.
  • Marquez les fonctions avec #[wasm_bindgen], réglez crate-type = ["cdylib", "rlib"], et compilez avec wasm-pack build --target web.
  • En JavaScript, faites d’abord await init(), puis appelez vos fonctions ; servez la page en http://, pas en file://.
  • Les nombres traversent la frontière à bas prix ; les chaînes et les slices sont copiées : faites donc peu d’appels, mais bien remplis.
  • Livrez des builds release compressés, et mesurez avant de faire confiance aux réglages de taille.

Petit quiz

1. Quelle commande ajoute la cible Rust pour le WebAssembly du navigateur ?

2. Que fait l'attribut #[wasm_bindgen] ?

3. Votre page reste bloquée sur « Chargement… » et la console affiche une erreur CORS pour une adresse file:///. Que faut-il faire ?

4. Lequel de ces éléments coûte le moins cher à passer de JavaScript à Rust ?

Score : 0 / 4