Le langage TypeScript est apprécié autant pour faire des interfaces Web que pour écrire des programmes en Node.js fonctionnant comme service, avec un accès complet aux ressources de la machine. Il est aussi possible d'écrire des applications traditionnelles en TypeScript, c'est-à-dire qui combinent un interface graphique et un accès à toutes les ressources de la machine, par exemple pour pouvoir accéder à une base de données ou à des fichiers. Nous allons donc explorer ce scénario, et voir comment accéder aux modules Yoctopuce dans un tel scénario.
La première idée qui émerge, lorsque l'on pense à une application combinant un moteur Node.js et une interface utilisateur HTML codée en TypeScript, consiste à lancer un petit serveur Web local en Node.js et à utiliser un simple navigateur pour l'affichage de l'interface:
Architecture client/serveur HTTP pour la conception d'une application entièrement en TypeScript
C'est une possibilité tout à fait valable: elle permet facilement à l'interface d'aller chercher des informations et de poster des modifications, par une interface REST par exemple. Par contre, il est plus compliqué d'envoyer des informations asynchrones depuis le moteur Node.js vers l'interface, ou de contrôler l'apparence de la fenêtre du navigateur.
Electron
La réponse un peu plus évoluée s'appelle Electron: c'est une solution intégrée qui comprend un interpréteur Node.js, un navigateur basé sur Chromium, et quelques classes compatibles TypeScript qui permettent aux deux d'échanger efficacement des informations par communication inter-process, ainsi que de contrôler en détail les fenêtres d'affichage. Les binaires sont disponibles pour Windows, Linux et MacOS. A priori, c'est donc une solution idéale! Mais attention, il y a quand même quelques pièges à éviter pour éviter qu'Electron ne se transforme en une usine à gaz...
Les dépendances à tiroir
De nombreux exemples utilisant Electron sur le Web cumulent frameworks, générateurs et librairies avec un prix considérable un terme de dépendances à des packages externes, lorsque les outils n'ont pas été choisis avec soin pour réduire ces dépendances. Le risque est de se retrouver par surprise avec un projet incluant un fragment de code sans importance mais qui introduit une vulnérabilité, ou simplement qui rend votre projet inutilisable parce que son auteur publie une mise à jour erronée ou simplement s'énerve. Si vous voulez en avoir le cœur net, essayez la commande suivante sur l'un de vos projets:
Pour être franc, Electron lui-même comprend quelques dépendances indirectes assez futiles. Il n'est pas toujours possible de faire la fine bouche, mais en règle générale, avant de choisir d'ajouter un package supplémentaire, il vaut la peine de vérifier ses dépendances, et le cas échéant, de passer quelques minutes de plus à chercher si une alternative avec moins de dépendances ne serait pas utilisable. Les outils que nous vous proposerons ci-dessous ont été choisis de cette manière.
Le lien entre la tâche principale et l'interface utilisateur
Le système de communication entre les deux tâches proposé par Electron est un mécanisme d'événement, qui permet de passer des structures complexes de manière asynchrone dans les deux sens. Mais pour des raisons de sécurité, la tâche d'interface est elle-aussi divisée en deux contextes distincts:
Architecture d'Electron
Cette séparation est nécessaire pour que le code interprété dans le navigateur n'ait jamais accès directement aux primitives à bas niveau de Node.js, sans quoi le simple affichage d'une page tirée d'un site externe représenterait un risque majeur. Mais le résultat de cette double séparation, si l'on suit la solution d'intégration avec TypeScript proposée par la documentation d'Electron, est une multiplication des déclarations pour définir les différents maillons de la chaîne de communication. Qui plus est, cette multiplication rompt la chaîne de validation des types à la compilation, puisque chaque contexte est compilé séparément. Nous vous proposerons donc une solution Yoctopuce qui permet de générer automatiquement ces déclarations lors du cycle de build, en préservant ainsi la chaîne de validation des types de bout en bout.
La compilation et le packaging
En TypeScript, chaque fichier source représente un module, avec des imports et des exports. Tous les modules vont donc devoir être compilés vers JavaScript puis liés ensembles avant de pouvoir être utilisés en production dans un environnement comme Electron.
La solution prédominante du moment pour faire cela s'appelle WebPack. WebPack est un petit monstre, qui ajoute à ce jour pas moins de 66 dépendances, et fait non seulement ce dont nous avons besoin mais aussi plein de choses qui ne nous servent à rien dans le cas présent, comme par exemple assurer la compatibilité avec des anciens navigateurs. Nous utiliserons donc un autre petit outil, plus simple, plus efficace et sans aucune dépendance.
Ensuite, pour distribuer l'application, la solution la plus courante est de construire directement un installeur avec electron-forge, electron-packager ou electron-builder. Ces outils un peu magiques, qui coûtent plus d'une cinquantaine de dépendances supplémentaires, ne sont indispensable que lorsqu'on ignore qu'on peut installer une application électron simplement en posant simplement quelques fichiers dans un répertoire ou qu'on ne sait pas comment le faire autrement...
Une solution minimaliste, sûre et confortable
Comme vous l'aurez deviné, nous vous proposons aujourd'hui une solution minimaliste pour utiliser Electron en TypeScript, mais sans compromis sur le confort et la sécurité du développement. Cette solution vous est offerte sous forme d'un projet de base réutilisable (un boilerplate).
En bref
Pour implémenter le lien sécurisé entre la tâche principale et l'interface, nous avons écrit un petit script automatique qui analyse le code TypeScript de la tâche principale et génère les trois fichiers sources TypeScript nécessaire à implémenter le canal de communication. Ces fichiers se retrouveront respectivement dans le code principal, dans le code de préchargement de l'interface et dans le code de l'interface elle-même. Les annotations de type sont utilisées de bout en bout, ce qui permet à l'éditeur et au compilateur de valider la cohérence de la chaîne complète, plutôt que de causer une erreur à l'exécution comme cela se passerait avec la solution originale proposée par Electron.
Pour la compilation et l'édition des liens (le bundling), nous utilisons esbuild. C'est l'outil le plus rapide du genre, sans aucune dépendance, qui remplace avantageusement WebPack pour l'occasion car il fait juste ce dont nous avons besoin.
Nous avons prévu deux modes de compilation et d'exécution. Le mode développement génère dans le répertoire debug/ du code JavaScript lisible et des fichiers .map permettant le deboggage dans le code source TypeScript. Dans ce mode, on lance la version d'électron installée localement, et l'application détecte automatiquement les changements que vous apportez aux fichiers sources pour les recompiler au vol.
Le mode production génère lui du code minifié, et l'emballe dans le fichier dist/resources/app.asar. Vous pouvez alors dézipper dans le répertoire dist/ les binaires redistribuables d'Electron correspondant à l'architecture de votre choix, en vous assurant que la version des librairies Electron que vous utilisez corresponde, et vous obtiendrez une application autonome qui peut être distribuée sous forme d'un zip ou à l'aide d'un installeur, comme n'importe quelle autre logiciel.
Last but not least, si vous aimez définir vos interfaces Web sous forme de composants à l'aide de la syntaxe JSX, permettant non seulement d'intégrer le code TypeScript et HTML, mais aussi de valider les types jusqu'aux attributs du DOM, ce projet de base est configuré pour intégrer preact, une version ultra-légère de React. Le projet d'exemple démontre son utilisation sur un exemple typique.
Structure du projet
Vous trouverez le projet sur GitHub dans notre collection d'exemples. Il contient les répertoires suivants:
- build/: Répertoire avec nos outils de build (écrits en TypeScript)
- debug/: Répertoire où sont posés et exécutés les fichiers compilés en mode développement
- dist/: Répertoire où sont posés les fichiers compilés en mode production, et où il suffit d'extraire les binaires distribuables d'Electron pour obtenir une application autonome
- src/: Répertoire contenant tous les fichiers sources de l'application
- src/Main/: Répertoire spécifique à la tâche principale
- src/UI/: Répertoire spécifique à la tâche d'interface
Utilisation
On supposera que vous avez déjà installé Node.js en version récente sur votre machine. Pour utiliser ce projet, commencez par copier les fichiers sur votre machine et installer ses quelques dépendances de développement:
Pour compiler la version de développement, vous pouvez utiliser:
Mais le plus pratique est en général de compiler et lancer directement l'application en mode développement, qui inclut le rechargement au vol à chaque modification des fichiers sources, avec la commande:
Pour compiler la version de production, utilisez:
Une fois que vous aurez posé les binaires distribuables d'Electron dans le répertoire dist/, vous pourrez aussi combiner compilation et lancement de la version de production avec la commande:
Notez toutefois que si vous avez renommé dist/electron[.exe] pour donner un nom plus personnalisé à l'exécutable, vous devrez changer en conséquence la ligne définissant "start-prod" dans le fichier package.json.
Finalement, si durant le développement vous désirez juste utiliser le générateur de code TypeScript pour la communication entre les tâches, vous pouvez aussi lancer:
pour une génération unique, ou encore
pour lancer un watcher qui recompilera l'interface à chaque changement de fichier dans le répertoire Main, de sorte à ce que la vérification sémantique des codes sources dans votre éditeur se fasse en temps réel. Notez que cette fonction est intégrée à la commande start-dev, il n'est donc pas nécessaire de la lancer en parallèle.
Rôle de chaque fichier
Comme le but n'est pas de vous donner un outil magique sans explication, voici des détails sur le rôle de chacun des fichiers source du projet:
- src/dev-main.ts: Point d'entrée de l'application en mode développement, qui lance en tâche de fond un processus de détection des fichiers d'interface modifiés pour les recharger au vol avant d'appeler startApplication.
- src/prod-main.ts: Point d'entrée de l'application en mode production, qui lance directement l'application startApplication dès que le moteur d'Electron est prêt.
- src/tsconfig.json: Configuration TypeScript de l'application elle-même, contenant notemment l'option noEmit:true pour que l'éditeur fasse les vérifications sémantiques mais ne fasse pas la génération de code JavaScript, puisque nous la faisons avec esbuild
- src/Main/main.ts: Implémente la méthode startApplication, qui va
- inclure mainHandlers pour mettre en place le canal de communication inter-process
- créer une tâche d'interface démarrant par src/UI/preload.ts
- ouvrir la fenêtre d'interface sur src/UI/App.html
- lancer les tâches de fond définies dans src/Main/mainAPI.ts
- stopper le tâches de fond et quitter lorsque l'interface sera fermée.
- src/Main/mainAPI.ts: Fichier qui implémente la classe mainAPI, où vous devrez mettre votre code pour la tâche de principale de votre application et implémenter les méthodes qui doivent être mises à disposition de la tâche d'interface. Vous pouvez naturellement importer depuis-là d'autres modules dont vous avez besoin pour définir votre tâche principale.
- src/Main/mainHandlers.ts: Fichier généré automatiquement, qui enregistre auprès de l'objet ipcMain des gestionnaires d'événements pour les méthodes qui doivent être mises à disposition de la tâche d'interface.
- src/UI/App.css: Fichier de stylesheet de l'interface utilisateur
- src/UI/App.html: Fichier racine de l'interface utilisateur chargé par Electron, quasi-vide puisque toute l'interface de notre exemple est définie en TypeScript/JSX. Il sera copié tel-quel dans le répertoire d'exécution de l'application.
- src/UI/App.tsx: Point d'entrée effectif de l'interface utilisateur. Comme nous avons choisi de faire cet exemple avec preact, l'interface est définie sous formes de classes représentant des composants affichables, et le fichier se termine par un appel à la fonction render() qui instancie le composant racine dans document.body. C'est ce fichier que vous modifierez pour y construire votre interface utilisateur et dans lequel vous pourrez importer les autres composants dont vous avez besoin. Bien entendu, si vous ne voulez pas utiliser preact et la syntaxe JSX, vous pouvez remplacer l'extension par .ts et mettre du simple code TypeScript.
- src/UI/preload.ts: Fichier généré automatiquement, qui sert de point d'entrée de la tâche d'interface, avant qu'elle soit transférée au navigateur. Ce fichier est le pendant dans la tâche d'interface de mainHandlers.ts, avec qui il communique via l'objet ipcRenderer. Les fonctions qu'il défini seront mises à disposition du navigateur via la passerelle contextBridge d'Electron.
- src/UI/preloadAPI.ts: Fichier généré automatiquement, qui peut être inclus dans les module de l'interface utilisateur pour accéder aux méthodes et fonctionnalités sélectionnées de mainAPI avec une interface typée.
Pour illustrer cela, voici le diagramme des flots d'exécution et de communication résultant:
Flots d'exécution et de communication de l'application
Ça fait pas mal de plomberie, il faut bien le reconnaître, mais au moins vous savez exactement à quoi sert chaque fichier, et rien n'est fait dans votre dos :-)
Fonctionnement du générateur
Mis à part les choix d'outils minimalistes, auxquels chacun est libre d'adhérer ou pas, le principal intérêt de ce boilerplate est le petit générateur de code TypeScript qui assure la communication entre la tâche principale et la tâche d'interface. Comme indiqué plus haut, ce générateur va créer:
- le fichier mainHandlers.ts, importé par main.ts, qui enregistre dans l'objet ipcMain les gestionnaires d'événements nécessaires aux appels de la tâche d'interface vers la tâche principale.
- le fichier src/UI/preload.ts, chargé à l'initialisation du BrowserWindow dans un contexte privilégié pour appeler les méthodes de ipcRenderer, et créer des points d'entrées sécurisé en utilisant le contextBridge d'Electron.
- le fichier src/UI/preloadAPI.ts, qui définit une interface typée utilisable depuis toutes les classes de l'interface pour accéder aux fonctions exposées par le contextBridge d'Electron.
C'est donc une réplication quasi-transparente pour le développeur des méthodes sélectionnées dans mainAPI, qui se retrouvent exposées à l'identique dans l'objet preloadAPI de la tâche d'interface.
Bien entendu, le but n'est pas d'exposer la totalité des méthodes de mainAPI, car certaines ne sont pas destinées à être utilisées par la tâche d'interface et doivent en être exclues, pour des raisons de sécurité. Pour choisir celles qui doivent être répliquées, le générateur se base sur la documentation des fonctions. Toutes les méthodes de l'objet mainAPI précédées d'un commentaire du type "Safe API: explications de la fonction" seront considérées comme sûres et mises à disposition de la tâche d'interface, avec le même prototype, dans l'objet preloadAPI. Les autres seront considérées comme spécifiques à la tâche principale.
Un mécanisme similaire permet à la tâche principale d'envoyer des données structurées de manière asynchrone vers la ou les tâches d'interface. Pour cela, l'objet mainAPI inclut une méthode send qui prend en argument un nom d'événement et un ou plusieurs arguments à envoyer. Suivant le même principe que pour les appels dans l'autre sens, le générateur repère dans tout le code de mainAPI les appels à la méthode mainAPI.send(), et crée l'interface typée correspondante dans preloadAPI pour pouvoir s'abonner à la réception de ces messages à l'aide d'un callback. Par exemple, si vous implémentez dans la tâche principale la méthode suivante:
{
this.logBuffer.push(msg);
if (this.logBuffer.length > MAX_LOG_LINES) {
this.logBuffer.splice(0, 100); // Remove 100 oldest lines
}
// Safe API: Advertise new log messages
mainAPI.send('log', msg);
}
alors le générateur ajoutera à l'interface preloadAPI les déclarations suivantes:
...
export interface PreloadAPI {
...
// Advertise new log messages
registerLogCallback(logCallback: LogCallback): UnsubscribeFn
...
}
Notez que le nom logCallback est directement dérivé du nom du message qui a été utilisé avec la méthode mainAPI.send. De même, le ou les types des paramètres de la fonction de callback sont déterminés par le type des arguments passé en paramètre à mainAPI.send.
Naturellement, sur un projet réel, vous aurez besoin de structurer votre tâche principale à l'aide de plusieurs classes couvrant des fonctionnalités différentes. Le générateur est capable de refléter cela aussi dans l'interface preloadAPI, sur la base du marquage avec le commentaire "Safe API:" des propriétés susceptibles de contenir des interfaces à répliquer. Par exemple, si vous définissez votre classe MainAPI comme suit:
{
// Safe API: Access to Yoctopuce device monitoring
public yoctoMonitor: YoctoMonitor;
// Safe API: Access to specific database functions
public dbAccess: DBAccessor;
// Safe API: Access to system logs
public logging: Logging;
constructor()
{
// Instantiate background tasks and helpers
this.logging = new Logging();
this.yoctoMonitor = new YoctoMonitor(['127.0.0.1']);
this.dbAccess = new DBAccessor();
}
...
}
et que les classes YoctoMonitor, DBAccessor et Logging déclarent elles-aussi des méthodes "Safe API:", vous les retrouverez dans l'interface de l'autre côté sous la forme suivante:
// Access to Yoctopuce device monitoring
yoctoMonitor: {
// Get an array of connected devices
getConnectedDevices(): Promise<string[]>
},
// Access to specific database functions
dbAccess: {
...
},
// Access to system logs
logging: {
...
},
...
}
ce qui permet à nouveau une utilisation transparente dans la tâche d'interface, à l'identique de ce que l'on aurait fait dans la tâche principale.
Accès aux modules Yoctopuce
Maintenant que nous avons un environnement de développement correct, revenons à l'objectif de base: implémenter en TypeScript une application à part entière utilisant les modules Yoctopuce.
Sachant que l'application est divisée en deux tâches, la première question à se poser est de savoir si la connexion aux modules doit se faire par la tâche d'interface ou par la tâche principale.
Si les modules Yoctopuce ne servent qu'à l'interface utilisateur (module d'affichage, buzzer, etc.), ou qu'à l'affichage en temps réel de valeurs mesurées par exemple, on peut choisir d'intégrer la librairie Yoctopuce dans la tâche d'interface. Dans ce cas, c'est un des fichiers du répertoire src/UI qui importera yoctolib-esm/yocto_api_html.
Par contre, si les modules sont utilisés pour une tâche de monitoring, de sauvegarde des mesures dans une base de données, ou que la connexion aux modules exige un mot de passe qui ne doit pas risquer d'être accessible dans le navigateur par un code malicieux, c'est un des fichiers du répertoire src/Main qui devra cette fois importer yoctolib-esm/yocto_api_nodejs et contrôler les modules. Vous pourrez néanmoins ensuite facilement partager les mesures en temps réel avec la tâche d'interface pour affichage, par exemple en incluant un appel à mainAPI.send() dans un callback de changement de valeur:
{
// Safe API: Advertise current temperature
mainAPI.send('temperature', parseFloat(value));
}
A titre d'illustration concrète, nous avons repris le petit exemple que nous avions publié sur ce blog il y a deux ans et nous l'avons remis au goût du jour, en TypeScript, en respectant les recommandations de sécurité. Le projet résultant est disponible sur GitHub dans un nouveau projet d'exemple.
Conclusion
Cette excursion en TypeScript dans l'univers d'Electron s'est révélée bien plus coûteuse à mettre en place qu'imaginé au départ. Aussi étrange que cela paraisse, les dizaines projets d'exemple Electron en TypeScript que nous avons trouvé sur GitHub souffraient tous du cancer des dépendances et de l'absence d'une véritable validation sémantique des types entre la tâche principale et la tâche d'interface. Au final nous avons réussi à vous proposer un environnement de développement conforme à nos attentes, mais nous étions loin d'imager qu'il nous faudrait pour cela récrire un petit analyseur de code TypeScript...
Nous aurions bien sûr pu fermer les yeux sur ces questions de sécurité et faire tourner tout le code dans un environnent unique combinant Node.js et le navigateur, comme c'est fait dans NW.js, ou en désactivant l'isolation de contexte dans Electron. Mais apparemment, si un tel soin a été mise dans Electron pour mettre en place ces barrières de sécurité, c'est que les failles de sécurité dues à la promiscuité entre node.js et le navigateur ont été nombreuses et sévères. La documentation précise désormais explicitement que l'isolation de contexte a été activée par défaut depuis Electron 12 [mars 2021], et est maintenant le réglage de sécurité recommandé pour toutes les applications. Y compris celles qui pensent qu'elles n'en ont pas besoin. Voilà au moins de quoi justifier nos efforts!