Construire une CLI pour notre conférence avec React

April 22, 2021
Rédigé par
Révisé par

cli-devmode-banner

Pour notre conférence annuelle SIGNAL, nous avons décidé de permettre à nos devs de pouvoir construire des projets cool avec Twilio, tout en regardant la conférence.

Résultat : nous avons construit le SIGNAL Developer Mode, une extension de la CLI Twilio.

Pourquoi une expérience en ligne de commande ?

Quand les conférences en visio ont commencé à proliférer cette année - dû au contexte sanitaire actuel - nous avons nous-même assisté à quelques-unes. Et nous avons réalisé qu’il y avait tout de même des différences avec les conférences en présentiel.

Nous avons remarqué que l’une de ces différences est l’envie de vouloir savoir plus et construire avec ce qui est mentionné dans les sessions keynotes et les conférences pendant qu’elles ont lieu.

Quand on assiste à une conférence en personne, on essaie de noter dans notre tête tout ce que l’on veut essayer plus tard, ou alors on l’écrit pour s’en rappeler. Mais avec les conférences virtuelles, on a absolument tout à disposition : notre navigateur, notre éditeur de texte et notre terminal.

Nous voulions permettre à n’importe quelle personne présente à la conférence SIGNAL de pouvoir construire et explorer les produits Twilio en même temps que nous les présentions. Nous avions déjà une CLI Twilio qui fournit un modèle de plugin que nous avons pu utiliser pour construire notre expérience.

En construisant par dessus la CLI Twilio, nous avons pu ajouter quelques fonctionnalités comme la configuration de variables pour les accès de compte dans les applications de démonstration.

Qu’est-ce que ça devrait faire ?

Comme l’enjeu premier était d’accompagner les personnes présentes à commencer à coder, la fonctionnalité majeure que nous voulions ajouter était la possibilité d’accéder à un catalogue d’applications simples et pertinentes, qui peut être automatiquement téléchargé et configuré.

capture d'écran de la CLI

A partir de là, nous nous sommes demandé s’il y avait d’autres idées qui pourraient être utiles pour les participants. Mis à part la section de ressources obligatoires avec tous les liens utiles, nous tenions à ajouter un petit quelque chose bien particulier.

Cela remonte à nos propres expériences de conférences virtuelles. Vous fournir des collections d’exemples que vous pouvez parcourir est une chose. Mais mettre à disposition des démos et des liens pertinents pendant que vous assistez à la présentation, via des sous-titres intégrés en direct c’est autre chose !

Nous avons aussi pensé à ajouter l’emploi du temps. C’est utile de l’avoir à portée de main à tout moment pour s'y référer, s’enregistrer pour les sessions ou ouvrir votre navigateur.

Quelle technologie utiliser ?

Comme nous codons par dessus la CLI Twilio, notre choix technique pour ce challenge semblait logique. Twilio CLI utilise le Open CLI Framework (oclif) basé sur Node.js

L'écosystème Node.js nous offre une énorme quantité d’outils et de librairies utiles pour construire des expériences CLI. Il peut être un bon point de départ pour qui veut construire des CLIs.

Twilio CLI avait déjà une librairie nommée inquirer pour gérer les saisies utilisateur. Pour la plupart des CLIs que je code, j’utilise cette librairie ou une similaire comme prompts ou enquirer. Mais nous voulions que celle-ci ressemble plus à une interface utilisateur graphique classique, afin de la rendre accessible aux débutants et aussi regrouper toutes les fonctionnalités en un seul endroit.

Pour l’écosystème Node.js, j’envisageais deux options que l’on pouvait utiliser. La première étant blessed, une librairie curses-like qui est idéale mais malheureusement plus maintenue.

L’autre alternative est un framework CLI basé sur React appelé ink. Il est en fait utilisé par quelques CLIs que vous avez peut-être déjà utilisés comme Gatsby, Parcel ou Yarn 2.

Utiliser React avec une CLI ?

Exécuter React dans une CLI peut paraître étrange aux premiers abords (ou même après un temps de réflexion d’ailleurs).

Le fonctionnement est similaire à React Native. En lui-même, React n’est pas vraiment une librairie spécifique aux navigateurs. Si, par le passé, vous avez codé du React pour le web, vous avez peut-être remarqué que vous utilisez deux dépendances, react et react-dom. Le seul moment où vous exécutez react-dom est pour appeler la fonction render qui va rendre votre application au DOM du navigateur.

ink correspond basiquement à react-dom mais au lieu d’être la passerelle entre React et le navigateur, c’est ce qui le relie au terminal. Il y a plusieurs raisons qui rendent ink idéal pour notre expérience CLI.

L’une de nos valeurs chez Twilio est le “Draw the Owl” qui prône la débrouillardise sans instructions : c’est ce qui nous permet parfois d’apprendre par nous-même. Notre situation rentrait dans ce cadre. Ink nous offre une interface qui ne nous limite pas à un ensemble de composants dont nous pouvons nous servir. C’est même plutôt l’inverse, il ne nous fournit qu’un choix limité mais puissant de composants. Un composant <Text> qui fonctionne comme un <span/> dans le navigateur et un composant <Box> qui est comme un <div/> dans le moteur de recherche avec le paramètre display: flex .

A partir de là, il n’y avait donc aucune limite pour construire notre UI. Nous avons été capable de créer nos propres composants réutilisables UI et de nous en servir à plusieurs endroits. Par exemple, nous avons créé <DemoEntry /> que nous avons pu rendre de la section démos à l’écran d’installation en passant par le mode session interactive.

import { Box, BoxProps } from 'ink';
import React, { PropsWithChildren, useMemo } from 'react';
import { Merge } from 'type-fest';
import { Demo, DemoLanguage } from '../../types/demo';
import { DemoDescription } from './DemoDescription';
import { DemoInfoHeader } from './DemoInfoHeader';

export type DemoEntryBoxProps = Merge<
 BoxProps,
 {
   demo: Demo;
   language?: DemoLanguage;
   showDescription?: boolean;
   activeLanguageIdx?: number;
   slim?: boolean;
 }
>;
export function DemoEntryBox({
 demo,
 language = undefined,
 showDescription = true,
 activeLanguageIdx = 0,
 children = undefined,
 slim = false,
 ...props
}: PropsWithChildren<DemoEntryBoxProps>) {
 const languages = useMemo(
   () => (language ? [language] : demo.options.map((o) => o.language)),
   [demo, language]
 );

 return (
   <Box
     borderStyle="single"
     flexDirection="column"
     paddingX={1}
     paddingY={slim ? 0 : 1}
     height={slim ? 6 : 8}
     minHeight={slim ? 6 : 8}
     {...props}
   >
     <DemoInfoHeader
       name={demo.name}
       languages={languages}
       activeLanguageIdx={activeLanguageIdx}
     />
     {showDescription ? (
       <DemoDescription>{demo.description}</DemoDescription>
     ) : null}
     {children}
   </Box>
 );
}

Une autre raison qui rendait cette solution idéale pour nous est que quelques fonctionnalités devaient imiter le site web SIGNAL qui était construit avec un framework React appelé Next.js. Cela signifie que collaborer est plus facile lorsque nous pouvons réutiliser les mêmes APIs ainsi que - majoritairement -  les mêmes librairies.

Le reste de la magie

Comme nous avons principalement construit un front-end en Node.js, on a eu le luxe de pouvoir exploiter quelques librairies front et back-end pour mener à bien notre mission.

Par exemple, la CLI se sert des bibliothèques Apollo Client et Apollo React pour interagir avec l’API GraphQL que le site web SIGNAL utilise.

Pour garder une trace des états plus complexes, il utilise le framework state machine XState, comme un navigateur front-end le ferait.

En même temps, il se sert des librairies spécifiques Node.js comme le Serverless Toolkit, les commandes pkg-install, configure-env et degit pour s’occuper de configurer notre d’application de démo et une partie du cheat mode caché.

Comme l’un des buts premiers était de montrer des infos pertinentes au bon moment dans la CLI pour l’expérience de prise de note, on devait synchroniser la CLI et vos sessions de navigations. Pour cela, nous utilisons le produit Twilio Sync, un outil WebSocket qui synchronise les états entre plusieurs clients et fonctionne à la fois dans le navigateur et dans Node.js.

Les challenges

Construire une CLI - surtout avec une interface utilisateur graphique - nous donne des challenges légèrement différents bien que globalement similaires à ceux que l’on aurait en construisant un site web.

L’un des objectifs challenges de cette expérience était que ça fonctionne pour le plus grand nombre possible de personnes. Ce qui signifie faire l’équivalent d’un test cross-browser. Mais au lieu de Firefox, Chrome, Edge, Safari etc… c’était Hyper, iTerm, Windows Terminal, Terminal.app, cmder, Command Prompt, Powershell, VS Code et d’autres encore.

Et à la place des modes normaux et sombres, nous avons des systèmes de couleur, plusieurs modes de support couleur et des ensembles de caractères limités.  Heureusement, des librairies comme chalk (appelée par ink) et comme figures assurent vos arrières sur au moins quelques points de ce challenge.

L’autre problème vient des différentes tailles de terminaux. Si l’on construit juste un texte basique sur la CLI, on ne se soucie normalement pas de la taille du terminal de l’utilisateur. En réalité, ink peut quand même gérer le text wrapping pour vous si votre texte à afficher est plus grand que ce que la taille du terminal permet d’afficher. Mais nous étions limités du fait d’avoir construit une UI qui prend le pas sur tout votre terminal. Vous pouvez imaginer ça comme la construction d’un site web où tout ce qui ne rentrerait pas dans la fenêtre du navigateur serait caché. Sauf que dans notre cas, ça ne l’était pas. Les caractères s'imprimaient les uns sur les autres, rendant le contenu illisible.

Comme ink utilise Yoga Layout pour gérer flex box, construire des interfaces responsives est assez facile ! Sauf qu’il n’y a pas d’équivalent aux options overflow: hidden ou overflow: scroll. Ce qui signifie que si notre contenu ne correspond pas, nous devons le cacher nous-même. On a fini par construire une collection de composants qui nous aident à faire ce boulot, soit basé sur la taille de fenêtre, ou sur la taille du composant.

import React, { PropsWithChildren } from 'react';
import { Merge } from 'type-fest';
import {
 shouldRenderBasedOnBreakpoint,
 useResponsiveWindowSize,
} from '../../hooks/useResize';
import { Breakpoint } from '../../utils/breakpoints';

export type RenderIfWindowSizeProps = Merge<
 Breakpoint,
 {
   fallback?: JSX.Element | null;
 }
>;
export function RenderIfWindowSize({
 children,
 fallback = null,
 ...breakpoint
}: PropsWithChildren<RenderIfWindowSizeProps>) {
 const { width: totalWidth, height: totalHeight } = useResponsiveWindowSize();
 const shouldRender = shouldRenderBasedOnBreakpoint(
   breakpoint,
   totalWidth || 0,
   totalHeight || 0
 );

 if (shouldRender) {
   return <>{children}</>;
 } else {
   return fallback ? <>{fallback}</> : null;
 }
}

Comme nous pouvons écouter les évènements de pressions des touches, nous avons aussi pu développer des composants scrollables qui affichent autant de texte qu’ils le peuvent puis les adapter en fonction d’évènements clés. Vous pouvez retrouver la collection complète de nos composants dans le répertoire GitHub.

Nous n’avons majoritairement aucun - ou très peu - de contrôle sur le dernier challenge à affronter. Chaque terminal présente le texte à sa façon et à sa propre vitesse. Le résultat est que quelques terminaux clignotent, et certains plus que d’autres quand le contenu est mis à jour. Pour essayer d’atténuer ça, nous avons tenté de réduire au minimum la quantité de re-renders sur les terminaux que nous savions plus susceptibles de souffrir de ce problème. Par exemple, notre barre de chargement n’affiche qu’un spinner que si nous sommes certains que ça n’affectera pas le terminal.

import { Text } from 'ink';
import Spinner from 'ink-spinner';
import React from 'react';
import { useAnimation } from '../../hooks/useAnimation';
import { useTerminalInfo } from '../../hooks/useTerminalInfo';

export type LoadingIndicatorProps = {
 text: string;
};
export function LoadingIndicator({ text }: LoadingIndicatorProps) {
 const { shouldAnimate } = useAnimation();
 const { isWindows } = useTerminalInfo();
 const spinnerType = isWindows ? 'line' : 'dots';
 return (
   <Text>
     <Text color="green">
       {shouldAnimate && (
         <>
           <Spinner type={spinnerType} />{' '}
         </>
       )}
     </Text>
     {text}
   </Text>
 );
}

Ce que j’ai appris

On est super enthousiastes de ce Mode Développeur SIGNAL, et d’un point de vue personnel, c’était très intéressant pour moi d’explorer un peu les limites du possible en ce qui concerne l’utilisation de React et ink sur un terminal. Construire quelque chose qui peut exploiter des concepts à la fois front et back-end via le même outil peut être incroyablement puissant.

Quand le moment de prototyper l’expérience est venu, j’ai écrit du code Node.js vanilla et j’ai utilisé le module import-jsx pour ajouter au projet n’importe quel fichier JSX. Mais au fur et à mesure que le projet grandissait, j’avais besoin de plus d’assurance dans les changements que je faisais, et j’ai fini par transitionner le projet sur TypeScript, étape par étape. Si vous voulez en savoir plus à ce propos, allez jeter un oeil à mon article sur comment transitionner des projets existants du JavaScript vers TypeScript.

Bien que j’ai rencontré des obstacles et ai pris conscience des limites, l’expérience a aussi fait naître des idées de futurs projets à construire avec cette stack. Cela m’a aussi motivé dans la décision d’investir plus de temps pour créer des composants plus réutilisables.

Si vous souhaitez consulter le code entier et aimeriez voir l’ensemble des composants et des hooks créés pour ce projet, ça se passe sur le répertoire GitHub ! Laissez-moi savoir si vous préférez qu’on les déplace dans une librairie à part.

Et enfin, un grand et massif merci à vous Vadim Demendes et Sindre Sorhus d’avoir maintenu tous les incroyables outils et bibliothèques qui ont construit les fondations de cette expérience.  

Si vous avez des questions ou si vous voulez montrer vos propres CLIs développées avec ink ou d’autres objets, sentez-vous libre de me contacter !