WebR : Mise en œuvre et création d'un composant React/Next.js
Nous allons apprendre à réaliser le composant React/Next.js suivant, permettant d'exécuter du code R dans le navigateur à l'aide de WebR :
Dessin d'un ❤
Script R permettant la réalisation d'un cœur sur un graphiqueSi vous souhaitez accéder au code final sans lire les explications, vous pouvez cliquer ici.
Avant-propos
WebR s’exécute directement dans le navigateur du client, via WebAssembly. Ainsi, les performances varient d'un client à l'autre (connexion réseau, performances...) selon l'appareil (ordinateur/mobile), mais aucune ressource du serveur n'est utilisée.
Installation
Il est possible d'intégrer la bibliothèque WebR de deux manières :
- Via NPM sur le serveur avec
npm install webr
; - Via l'utilisation d'un CDN
<script src="https://webr.r-wasm.org/latest/webr.mjs"></script>
.
Dans cet article, j'ai choisi d'utiliser NPM.
Choix des paramètres
WebR permet d’exécuter des scripts R depuis un navigateur. Pour mon composant, je vais donc lui passer les paramètres suivants :
- Un nom obligatoire qui s'affichera sous forme de titre ;
- Une description optionnelle qui pourra informer sur l'utilité du script ;
- Le code R, obligatoire ;
- Les fichiers d'entrée (optionnels) sur lesquels le script se base ;
- Les fichiers de sortie (optionnels) que le script pourrait générer ;
- Les bibliothèques (optionnelles) à installer.
Voici la définition de mon composant React :
(ts)1export default function WebRConsole({ name, description, code, inputFiles = [], outputFiles = [], library = [] }: { 2 name: string, // Titre pour l'affichage 3 description?: string, // Description pour l'affichage 4 code: string, // Code R à exécuter 5 inputFiles?: FileDescription[], // Fichiers d'entrée 6 outputFiles?: FileDescription[], // Fichiers de sortie 7 library?: string[] // Bibliothèques à installer 8}) {
J'ai également créé le type FileDescription
pour permettre de donner :
- un nom ;
- une description ;
- le chemin vers le fichier dans le système de fichier WebR ;
- et enfin, un booléen qui indique si le fichier est nécessaire à l'exécution du script (pour les entrées) :
(ts)1export type FileDescription = { 2 path: string; 3 displayName?: string; 4 description?: string; 5 optional?: boolean; 6};
Console
Les délais pouvant être plus ou moins longs suivant l'appareil utilisé, j'ai décidé d'afficher une console informant sur les actions en cours, ajoutant du dynamisme et réduisant la sensation d'attente pour les longs scripts.
Pour cela, j'ai d'abord décidé de trois types d'éléments à y afficher :
- les titres : informations importantes qui seront mises en avant ;
- les consolePrompts : les éléments saisis dans la console WebR ;
- les consoleResults : les retours de la console WebR ;
(ts)1enum consoleStyles { 2 title = 'title', 3 consolePrompt = 'prompt', 4 consoleWarning = 'warning', 5 consoleResult = 'result' 6}
associé à du style CSS :
(css)1.console { 2 margin-top: 1rem; 3 padding: 1rem; 4 color: #FFF; 5 background-color: #111; 6 font-family: monospace; 7 white-space: pre-wrap; 8 overflow-y: auto; 9} 10 11.console .title { 12 font-weight: bold; 13} 14 15.console .prompt { 16 color: #0F0; 17} 18.console .prompt::before { 19 content: "> "; 20} 21 22.console .warning { 23 color: #FB0; 24} 25.console .warning::before { 26 content: "⚠️ "; 27}
L'ajout d'éléments dans la console sera géré via useState et la fonction addLineToConsole
:
(ts)1const [rConsoleOutput, setRConsoleOutput] = useState<{ content: string, style: consoleStyles}[]>([]); 2 3function addLineToConsole(line: string, style: consoleStyles) { 4 setRConsoleOutput(prev => [...prev, 5 { content: line, style } 6 ]); 7 8 if (style === consoleStyles.title) { 9 setResult(line); 10 } 11}
Enfin, l'affichage de la console se fera avec le code suivant :
(tsx)1{rConsoleOutput.length > 0 && ( 2 <div className={styles.console}> 3 <h3>🖥️ Console R</h3> 4 {rConsoleOutput.map((line, idx) => ( 5 <div key={idx} className={styles[line.style]}>{line.content}</div> 6 ))} 7 </div> 8)}
Interactions avec la console WebR
Afin d'éviter de surcharger la console webr
et d'obtenir un affichage correct, nous allons créer le couple de fonctions suivant :
(ts)1let resolvePromptReady: (() => void); 2const waitForPrompt = () => new Promise<void>((resolve) => (resolvePromptReady = resolve));
waitForPrompt
sera utilisé pour attendre que la console ait terminé son traitement en cours avant d'envoyer de nouvelles instructions.resolvePromptReady
sera appelé par la consolewebr
lorsque l'événementprompt
sera appelé, résolvant ainsiwaitForPrompt
.
Nous allons aussi créer la variable isConsoleActive
afin de cacher les éléments de la console webr
au démarrage :
(ts)1let isConsoleActive = false;
L'interaction avec WebR se fera en important l'élément Console
du package webr
:
(tsx)1const { Console } = await import('webr');
Celle-ci sera instanciée de la manière suivante :
(ts)1const webRConsole = new Console({ 2 stdout: (line: string) => { 3 // Affichage seulement si la console est active 4 if(isConsoleActive) addLineToConsole(line, consoleStyles.consoleResult); 5 }, 6 stderr: (line: string) => { 7 addLineToConsole(line, consoleStyles.consoleWarning); 8 }, 9 prompt: () => { 10 resolvePromptReady(); 11 } 12});
La console sera donc démarrée ainsi :
(ts)1webRConsole.run(); 2await waitForPrompt(); // Attente pour que webr soit prêt et lance le prompt 3isConsoleActive = true; // On autorise l'affichage dans la console
permettant de cacher les éléments non désirés.
L'envoi des éléments du script pourra ainsi de dérouler comme suit :
(ts)1for (const line of code.split('\n')) { 2 const trimmed = line.trim(); 3 if (trimmed.length > 0) { 4 addLineToConsole(trimmed, consoleStyles.consolePrompt); 5 webRConsole.stdin(trimmed + '\n'); 6 await waitForPrompt(); // Attente avant d'envoyer la prochaine ligne 7 } 8}
Interactions avec le système de fichiers
WebR crée son propre système de fichiers dans lequel il faudra charger les fichiers d'entrée :
(ts)1for(const fileDesc of inputFiles) { 2 const file = inputFileBuffers[fileDesc.path]; 3 if(file) { 4 const buffer = await file.arrayBuffer(); 5 const uint8Buffer = new Uint8Array(buffer); 6 webRConsole.webR.FS.writeFile(fileDesc.path, uint8Buffer); 7 } 8}
Pour la récupération des fichiers de sortie, il faudra procéder ainsi :
(ts)1const fileData = await webRConsole.webR.FS.readFile(fileDesc.path); 2const blob = new Blob([fileData]); 3const url = URL.createObjectURL(blob); 4newDownloadUrls[fileDesc.path] = url;
Composant React final
Fichier : webrconsole.tsx (tsx)1'use client'; 2 3import { useState } from 'react'; 4import styles from './webrconsole.module.css'; 5 6export type FileDescription = { 7 path: string; 8 displayName?: string; 9 description?: string; 10 optional?: boolean; 11}; 12 13enum consoleStyles { 14 title = 'title', 15 consolePrompt = 'prompt', 16 consoleWarning = 'warning', 17 consoleResult = 'result' 18} 19 20export default function WebRConsole({ 21 name, 22 description, 23 code, 24 inputFiles = [], 25 outputFiles = [], 26 library = [] 27}: { 28 name: string, 29 description?: string, 30 code: string, 31 inputFiles?: FileDescription[], 32 outputFiles?: FileDescription[], 33 library?: string[] 34}) { 35 const [result, setResult] = useState<string | null>(null); 36 const [downloadUrls, setDownloadUrls] = useState<Record<string, string> | null>(null); 37 const [inputFileBuffers, setInputFileBuffers] = useState<Record<string, File>>({}); 38 const [rConsoleOutput, setRConsoleOutput] = useState<{ content: string, style: consoleStyles}[]>([]); 39 40 function addLineToConsole(line: string, style: consoleStyles) { 41 setRConsoleOutput(prev => [...prev, 42 { content: line, style } 43 ]); 44 45 if (style === consoleStyles.title) { 46 setResult(line); 47 } 48 } 49 50 const runR = async () => { 51 52 setDownloadUrls(null) 53 setRConsoleOutput([]) 54 setResult(null) 55 56 try { 57 addLineToConsole('1/6 - Initialisation de WebR...', consoleStyles.title); 58 const { Console } = await import('webr'); 59 60 let consoleActive = false; 61 let resolvePromptReady: (() => void); 62 const waitForPrompt = () => new Promise<void>((resolve) => (resolvePromptReady = resolve)); 63 64 const webRConsole = new Console({ 65 stdout: (line: string) => { 66 if (consoleActive) addLineToConsole(line, consoleStyles.consoleResult); 67 }, 68 stderr: (line: string) => { 69 addLineToConsole(line, consoleStyles.consoleWarning); 70 }, 71 prompt: () => { 72 resolvePromptReady(); 73 //addLineToConsole('> ____', consoleStyles.consoleResult); 74 } 75 }); 76 77 addLineToConsole('2/6 - Chargement des fichiers...', consoleStyles.title); 78 if(inputFiles) { 79 for (const fileDesc of inputFiles) { 80 const file = inputFileBuffers[fileDesc.path]; 81 if (file) { 82 const buffer = await file.arrayBuffer(); 83 const uint8Buffer = new Uint8Array(buffer); 84 webRConsole.webR.FS.writeFile(fileDesc.path, uint8Buffer); 85 } 86 } 87 } 88 89 addLineToConsole('3/6 - Installation des paquets...', consoleStyles.title); 90 if(library) { 91 for (const pkg of library) { 92 await webRConsole.webR.installPackages(pkg); 93 } 94 } 95 96 addLineToConsole('4/6 - Attente de la console WebR...', consoleStyles.title); 97 webRConsole.run(); 98 await waitForPrompt(); 99 consoleActive = true; 100 101 addLineToConsole('5/6 - Exécution du script R...', consoleStyles.title); 102 for (const line of code.split('\n')) { 103 const trimmed = line.trim(); 104 if (trimmed.length > 0) { 105 addLineToConsole(trimmed, consoleStyles.consolePrompt); 106 webRConsole.stdin(trimmed + '\n'); 107 await waitForPrompt(); 108 } 109 } 110 111 consoleActive = false; 112 113 addLineToConsole('6/6 - Récupération des fichiers de sortie...', consoleStyles.title); 114 if(outputFiles) { 115 const newDownloadUrls: Record<string, string> = {}; 116 for (const fileDesc of outputFiles) { 117 try { 118 const fileData = await webRConsole.webR.FS.readFile(fileDesc.path); 119 const blob = new Blob([fileData]); 120 const url = URL.createObjectURL(blob); 121 newDownloadUrls[fileDesc.path] = url; 122 } 123 catch { 124 console.warn(`Le fichier de sortie '${fileDesc.path}' est introuvable.`); 125 } 126 } 127 setDownloadUrls(newDownloadUrls); 128 } 129 130 addLineToConsole('✅ Script terminé avec succès !', consoleStyles.title); 131 } catch (err: any) { 132 console.error('Erreur WebR :', err); 133 addLineToConsole('❌ Une erreur est survenue : ' + (err.message || err.toString()), consoleStyles.title); 134 setDownloadUrls({}); 135 } 136 }; 137 138 const handleFileChange = (path: string, file: File | null) => { 139 if (file) { 140 setInputFileBuffers(prev => ({ ...prev, [path]: file })); 141 } 142 }; 143 144 const requiredFilesReady = !inputFiles || inputFiles 145 .filter(file => !file.optional) 146 .every(file => inputFileBuffers[file.path]); 147 148 return ( 149 <div className={styles.webrconsole}> 150 <p className={styles.h2}>{name}</p> 151 {description && <span>{description}</span>} 152 153 <pre>{result}</pre> 154 155 {inputFiles && inputFiles.length > 0 && ( 156 <div> 157 <h3 className={styles.h3}>Paramètre{inputFiles.length > 1 ? 's' : ''}</h3> 158 159 <ul className={styles.inputList}> 160 {inputFiles.map(file => ( 161 <li key={file.path} className={styles.inputItem}> 162 <label className={styles.inputLabel}> 163 <span> 164 {file.displayName || file.path} 165 {file.optional && <em className={styles.optionalTag}> (optionnel)</em>} 166 </span> 167 168 <input 169 type="file" 170 className={styles.inputField} 171 onChange={e => handleFileChange(file.path, e.target.files?.[0] || null)} /> 172 </label> 173 {file.description && ( 174 <div className={styles.inputDescription}> 175 {file.description} 176 </div> 177 )} 178 </li> 179 ))} 180 </ul> 181 </div> 182 )} 183 184 {requiredFilesReady? ( 185 <button onClick={runR} className={styles.runButton}> 186 Exécuter le script 187 </button> 188 ) : ( 189 <button disabled className={styles.runButton + ' ' + styles.disabled} title="Veuillez d'abord fournir tous les fichiers requis"> 190 🚫 Fichiers manquants 191 </button> 192 )} 193 194 {rConsoleOutput.length > 0 && ( 195 <div className={styles.console}> 196 <h3>🖥️ Console R</h3> 197 {rConsoleOutput.map((line, idx) => ( 198 <div key={idx} className={styles[line.style]}>{line.content}</div> 199 ))} 200 </div> 201 )} 202 203 {outputFiles && outputFiles.length > 0 && downloadUrls !== null && ( 204 <div> 205 <h3 className={styles.h3}> 206 Résultat{outputFiles.length > 1 ? 's' : ''} 207 </h3> 208 209 <ul className={styles.inputList}> 210 {outputFiles.map(file => ( 211 <li key={file.path} className={styles.inputItem}> 212 <label className={styles.inputLabel}> 213 {file.displayName || file.path} 214 {downloadUrls && downloadUrls[file.path] ? ( 215 <> <a 216 href={downloadUrls[file.path]} 217 download={file.displayName || file.path} 218 > 219 📥 Télécharger 220 { file.path.endsWith('.png') && 221 <> <img className={styles.outputImage} src={downloadUrls[file.path]} /></> 222 } 223 </a> 224 </> 225 ) : ( 226 <>⚠️ Fichier non disponible</> 227 )} 228 </label> 229 {file.description && ( 230 <div className={styles.inputDescription}> 231 {file.description} 232 </div> 233 )} 234 </li> 235 ))} 236 </ul> 237 </div> 238 )} 239 240 <div className={styles.footer}>Généré via <a target="_blank" href="https://docs.r-wasm.org/webr/latest/">WebR</a></div> 241 </div> 242 ); 243}
Fichier : webrconsole.module.css (css)1.webrconsole { 2 padding: 10px; 3 border: 1px solid black; 4} 5 6.h2 { 7 font-size: 1.3em; 8 font-weight: bold; 9} 10.h3 { 11 margin-bottom: 0; 12} 13 14.console { 15 margin-top: 1rem; 16 padding: 1rem; 17 color: #FFF; 18 background-color: #111; 19 font-family: monospace; 20 white-space: pre-wrap; 21 overflow-y: auto; 22} 23 24.console .title { 25 font-weight: bold; 26} 27 28.console .prompt { 29 color: #0F0; 30} 31.console .prompt::before { 32 content: "> "; 33} 34 35.console .warning { 36 color: #FB0; 37} 38.console .warning::before { 39 content: "⚠️ "; 40} 41 42.inputList { 43 list-style: none; 44 margin: 0; 45} 46 47.inputItem { 48 padding: 0.5rem 0; 49 border-bottom: 1px solid #ddd; 50} 51 52.inputLabel { 53 font-weight: bold; 54 font-size: 1rem; 55 color: #333; 56} 57 58.inputField { 59 margin-left: 0.7rem; 60} 61 62.inputDescription { 63 font-size: 0.9rem; 64 color: #666; 65} 66 67.runButton { 68 margin: 1rem 0; 69 padding: 0.5rem 1rem; 70 font-size: 1rem; 71 background-color: #1976d2; 72 color: white; 73 border: none; 74 cursor: pointer; 75 transition: background-color 0.2s ease-in-out; 76} 77 78.runButton:hover { 79 background-color: #1565c0; 80} 81 82.runButton.disabled { 83 background-color: #ccc; 84 color: #666; 85 cursor: not-allowed; 86} 87 88.webrconsole img, .outputImage { 89 max-width: 150px; 90 max-height: 100px; 91 vertical-align: middle; 92} 93 94.footer { 95 font-size: .8em; 96 text-align: right; 97}
― Valentin LORTET