WebR : Mise en œuvre et création d'un composant React/Next.js

Dans cet article, je décris l'implémentation d'une console R (via WebR) dans 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 graphique

Si 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 console webr lorsque l'événement prompt sera appelé, résolvant ainsi waitForPrompt.

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

Cet article vous a plu ? N'hésitez pas à le partager.

Découvrir d’autres articles