Aller au contenu principal
GiwiSoft
05

WASI expliqué à un développeur Java/Node

Giwi 11 min de lecture Web, JavaScript

Dans mon précédent article sur WebAssembly côté serveur, j’ai montré comment exécuter un module WASM qui ne fait que du calcul pur. Mais un vrai programme serveur a besoin de bien plus que du calcul : il lui faut lire et écrire des fichiers, lire des variables d’environnement, écrire sur la sortie standard, lire l’horloge, ouvrir des connexions réseau. Le problème, c’est qu’à l’origine, WASM ne sait pas faire tout ça. Il ne voit ni le système de fichiers, ni le réseau, ni l’horloge : rien.

C’est exactement le problème que résout WASI, la WebAssembly System Interface. Dans cet article, je vais l’expliquer à des développeurs qui connaissent un runtime comme la JVM ou Node.js, parce que c’est là que les parallèles sont les plus éclairants. Si vous comprenez ce qu’est libc pour un binaire C, ou ce que sont les modules fs, net, path pour Node.js, vous êtes déjà à mi-chemin de comprendre WASI.

Le problème : WASM n’a pas d’appels système

Quand vous compilez du C en code natif, le binaire contient des appels système qui s’interfacent directement avec le noyau : open, read, write, socket, getenv… Quand ce binaire s’exécute, son gestionnaire d’exécution fait le pont entre l’appel et le noyau du système d’exploitation.

Un module WASM, lui, ne contient aucun appel système. Il a été conçu pour être ultra-portable et ultra-sûr : il ne parle qu’à sa mémoire linéaire et aux fonctions que l’hôte a bien voulu lui importer. Ni fichiers, ni réseau, ni environnement. C’est ce qui le rend si sûr (il ne peut rien faire de dangereux par accident), mais c’est aussi ce qui le rend inutile pour un programme qui doit interagir avec le monde.

graph LR subgraph "Programme natif" A[Code C] --> B[libc] B --> C[Syscalls noyau] C --> D[OS] end subgraph "Module WASM" E[Code compilé] --> F[WASI] F --> G[Imports host] G --> H[Runtime / OS] end

C’est un peu comme si vous aviez un processeur virtuel capable d’exécuter du code, mais sans aucune instruction pour lire un fichier ou écrire sur un écran. WASI est précisément la couche qui fournit cette interface, de manière standard et portable.

L’analogie : WASI est au WASM ce que libc est au C, ou les modules Node sont au JS

La meilleure façon de comprendre WASI, c’est par analogie avec ce que vous connaissez déjà.

Pour un développeur Java : un programme Java ne fait jamais d’appel système directement. Quand vous écrivez new FileInputStream("data.txt"), c’est la JVM qui s’occupe d’appeler le système au nom de votre code, via son code natif et la bibliothèque du système. La JVM vous offre une interface de haut niveau (le java.nio, java.net, System.getenv) qui cache la diversité des systèmes. WASI joue exactement ce rôle pour un module WASM : c’est l’interface standard par laquelle le code accède aux ressources, tout en restant portable.

Pour un développeur Node.js : quand votre script fait require('fs').readFileSync, ou process.env, ou console.log, il utilise les modules intégrés de Node qui font le pont avec le système. Le code JS lui-même ne voit jamais le système : c’est la couche native de Node qui s’en charge. WASI, c’est cette même idée, mais normalisée et universelle : au lieu que chaque runtime définisse sa propre interface, tout le monde s’accorde sur la définition WASI, et un module compilé pour WASI fonctionne sur n’importe quel runtime qui l’implémente.

La différence philosophique centrale, c’est le write once, run anywhere. Java le promettait via la JVM (une JVM différente par plateforme). Node le fait via des modules (un runtime Node par plateforme). WASI + WASM le fait via un binaire universel : le même module .wasm tourne sur n’importe quel runtime WASI, sans recompilation ni adaptation.

Le fonctionnement de WASI en détail

WASI n’est pas une monolithique. C’est un ensemble d’interfaces par domaine, chacune définissant un ensemble de fonctions importées dans le module. Voici les principaux domaines.

Le système de fichiers. C’est l’un des plus matures. WASI expose des fonctions par poignées (file descriptors), comme en POSIX : path_open, fd_read, fd_write, fd_readdir, path_filestat_get… Surtout, le modèle de permission est explicite : le runtime doit pré-compacter (préouvrir) les répertoires auxquels le module peut accéder.

Les variables d’environnement. environ_get et environ_sizes_get permettent au module de lire les variables d’environnement, mais là encore, ce que le module voit est contrôlé par le runtime. Vous pouvez injecter uniquement les variables que le module est censé connaître.

L’entrée-sortie standard. fd_read et fd_write sur les descripteurs standards (stdin, stdout, stderr) permettent les logs et l’interaction simple avec le terminal.

L’horloge. Les fonctions de temps (clock_time_get, clock_res_get) donnent accès à une horloge, généralement contrôlée par l’hôte pour la déterminisme et le sandboxing.

Les sockets. C’est le chapitre le plus récent (wasi-sockets), et celui qui reste le moins uniformément supporté. Il permet des connexions réseau, mais la maturité varie selon les runtimes.

Le modèle de sécurité, c’est du moindre privilège : par défaut, le module n’a accès à rien. Chaque fonctionnalité doit être explicitement allouée par l’hôte. C’est le contraire de la plupart des runtimes classiques, où un programme a, par défaut, un accès complet au système.

Un exemple pratique : un programme C + WASI

Rien ne vaut un exemple concret. Prenons un programme C très simple qui lit un fichier et affiche son contenu.

#include <stdio.h>

int main(void) {
FILE *f = fopen("data.txt", "r");
if (!f) {
perror("fopen");
return 1;
}
char buf[128];
while (fgets(buf, sizeof(buf), f)) {
printf("%s", buf);
}
fclose(f);
return 0;
}

Ce programme utilise fopen, fgets, printf : des fonctions de la bibliothèque C standard. Pour le compiler en module WASI, il faut une cible spéciale avec une implémentation de libc adaptée à WASI.

# installation de la cible WASI pour clang
# puis compilation
clang --target=wasm32-wasi \
-O2 -o cat.wasm cat.c

La cible wasm32-wasi dit au compilateur : produit du bytecode WASM 32 bits, et lie contre la libc WASI. Le résultat cat.wasm contient non seulement le code, mais aussi les imports WASI nécessaires (fd_read, fd_write, path_open, etc.).

Maintenant, exécutons ce module avec wasmtime, le runtime de référence :

# un fichier de test
echo "Bonjour depuis WASI" > data.txt

# --dir donne au module l'accès au répertoire courant
wasmtime run --dir=. cat.wasm

La sortie :

Bonjour depuis WASI

L’option --dir=. est l’élément clé : elle dit à wasmtime de préouvrir le répertoire courant et de le rendre accessible au module. Sans elle, le module n’a chimérique aucun accès au système de fichiers, et fopen échouerait. C’est l’illustration parfaite du modèle de permission explicite de WASI.

L’équivalent en Node.js

Pour un développeur Node, cet accès au système de fichiers est trivial et par défaut. Comparons.

import { readFile } from 'node:fs/promises';

try {
const data = await readFile('data.txt', 'utf-8');
console.log(data);
} catch (err) {
console.error('fopen:', err.message);
process.exit(1);
}

La différence n’est pas dans la complexité des deux versions, qui est similaire. Elle est dans le modèle d’accès : en Node, readFile a accès à tout le système de fichiers par défaut. En WASI, un module n’a accès qu’à ce que le runtime lui a explicitement préouvert. Dans le premier cas, la sécurité repose sur la confiance dans le code ; dans le second, elle est garantie par l’architecture.

On peut aussi exécuter le module WASI directement depuis Node.js, grâce à l’API wasi intégrée (ou via Wasmtime). Voici un aperçu :

import { WASI } from 'node:wasi';
import { readFile } from 'node:fs/promises';

const wasi = new WASI({
version: 'preview1',
preopens: { '.': '.' }, // préouvre le répertoire courant
});

const bytes = await readFile('./cat.wasm');
const { instance } = await WebAssembly.instantiate(bytes, {
wasi_snapshot_preview1: wasi.wasiImport,
});

wasi.initialize(instance);
const { mem } = instance.exports;

On voit le mécanisme d’import : on passe à WebAssembly.instantiate un objet d’imports, où l’espace de noms wasi_snapshot_preview1 (c’est le nom d’import standard de WASI preview1) est fourni par la classe WASI de Node. C’est exactement la même mécanique que j’avais esquissée dans l’article précédent, mais ici l’import n’est pas vide : c’est toute l’interface système.

Le même principe que write once run anywhere

Il y a un parallèle historique amusant à faire ici. Java a popularisé le slogan “write once, run anywhere” dans les années 90 : vous compilez un .class une fois, et la JVM l’exécute sur tout système pourvu d’une JVM. WASI pousse l’idée plus loin de deux manières.

D’abord, la portabilité est au niveau du binaire, pas de la machine virtuelle. Un .class Java est lui aussi portable, mais la JVM reste une grosse couche à installer et maintenir sur chaque plateforme. Un module WASI est un binaire petit et autonome qui tourne sur n’importe quel runtime WASI, y compris embarqué dans un process, un navigateur, un serveur edge. C’est anywhere, vraiment partout.

Ensuite, le modèle de permissions est beaucoup plus fin. La JVM, par défaut dans la plupart des déploiements, donne à votre code un accès complet au système (les SecurityManager historiques ont été abandonnés). WASI part du principe inverse : aucun accès, et on en accorde par unité fine et explicite.

Node.js, lui, n’a jamais prétendu à la portabilité binaire : un script JS tourne là où il y a Node, et le JS n’est pas compilé en un artefact portable de la même façon. WASI offre la portabilité du code natif compilé avec le confort d’une interface de programmation standard.

Tableau comparatif : JVM vs Node vs WASI

Pour synthétiser, comparons les trois modèles sur plusieurs axes.

Critère JVM (Java) Node.js WASI (WASM)
Artefact .class / .jar Script JS / bundle Module .wasm
Portabilité Via JVM par plateforme Via Node par plateforme Via runtime WASI universel
Accès aux appels système Via libs natives de la JVM Via modules fs/net/path Via interface WASI standard
Modèle de permissions Large par défaut Large par défaut Aucun par défaut, explicite
Réseau java.net (mature) net (mature) wasi-sockets (jeune)
Démarrage “ warm “ mais lent au cold rapide quasi instantané
Langages JVM (Java, Kotlin, Scala, …) JavaScript/TypeScript Ceux qui compilent vers WASM
Exemple ressource FileInputStream fs.readFileSync path_open (via WASI)

La dernière ligne résume l’analogie : là où Java utilise java.io et Node utilise fs, un module WASM utilise WASI. Ce sont trois façons différentes d’exposer la même idée : une interface standard entre le code et le système.

Les précautions à prendre

Je veux terminer par des conseils, car WASI est prometteur mais a ses aspérités.

La version preview1 vs preview2. L’écosystème est en transition entre WASI preview1 (l’ancienne interface, monolithique) et preview2 (la nouvelle, composable par composants, utilisant le format component model). Beaucoup de runtimes supportent encore surtout preview1. Vérifiez la version supportée par votre cible avant d’engager.

L’accès aux sockets, le point faible. Si votre service fait beaucoup d’appels réseau sortants, savoir que wasi-sockets est encore jeune et inégalement supporté peut changer votre décision. Pour du calcul et de l’entrée-sortie sur fichiers, c’est serein ; pour du réseau intense, testez d’abord.

La gestion des données et du déterminisme. Bien que WASI permette de lire l’horloge et l’environnement, pour des workloads reproductibles il faut souvent les injecter de manière contrôlée (par exemple, une horloge simulée). C’est un mindset qu’un développeur Java ou Node n’a pas naturellement, habitué à voir le vrai système.

Conclusion

WASI est à WebAssembly ce que l’interface système est à n’importe quel runtime : la couche qui transforme un isolat de calcul en un véritable programme capable d’interagir avec le monde. Pour un développeur Java ou Node, WASI ne devrait pas être un concept étranger, mais une déclinaison familière de ce que vous faites déjà : une interface standard qui expose fichiers, environnement, stdio, horloge et réseau à votre code, de manière portable.

Ce qui distingue WASI, c’est la combinaison d’une portabilité binaire totale (le même module partout) et d’un modèle de sécurité par défaut fermé (rien n’est accessible sans autorisation explicite). C’est cette alliance qui en fait une base solide pour le calcul côté serveur et le edge computing.

Si vous voulez explorer par vous-même, je vous recommande de compiler un petit programme Rust ou C vers wasm32-wasi, de l’exécuter avec wasmtime en jouant avec les options --dir et --env, puis de faire le même exercice en appelant le module depuis Node.js. Vous verrez en quelques minutes à quel point la mentalité de moindre privilège change la façon dont vous pensez vos programmes côté serveur.