Using Electron in TypeScript, the way we like it

Using Electron in TypeScript, the way we like it

People like the TypeScript language as much to create web interfaces as to write Node.js software which work as a service, with complete access to the machine resources. You can also write traditional applications in TypeScript, that is applications which combine a graphical interface and access to all the resources of the machine, for example to access a database or files. We are going to explore this scenario and see how to access Yoctopuce modules in such a scenario.


The first idea that comes to mind, when you think about an application combining a Node.js motor and an HTML user interface coded in TypeScript, is to run a small local web server in Node.js and to use a simple browser to display the interface.

HTTP client/server architecture for the design of an application entirely in TypeScript
HTTP client/server architecture for the design of an application entirely in TypeScript


It's a very valid possibility: it enables the interface to easily look for information and to post the changes, with a REST interface for example. However, it's more difficult to send asynchronous information from the Node.js motor to the interface, or to control the appearance of the browser window.

Electron

The slightly more evolved answer is called Electron: it's an integrated solution containing a Node.js interpreter, a browser based on Chromium, and a few TypeScript compatible classes which allow both to efficiently exchange information through inter-process communication, and to control in details the display windows as well. Binaries are available for Windows, Linux, and MacOS. At first sight, it's therefore the ideal solution! but beware, there are still some pitfalls to avoid so that Electron doesn't turn into a complex labyrinth...

Dependencies within dependencies

Many examples on the web using Electron combine frameworks, generators, and libraries, with a considerable price to pay in terms of dependencies to external packages, when the tools weren't chosen with care to reduce these dependencies. The risk is to unknowingly find oneself with a project including an unimportant piece of code which introduces a vulnerability, or simply makes your project unusable because its author publishes an incorrect update or simply gets angry. If you want to know for sure, try the following command on one of your projects:

npm list --depth=10


To be clear, Electron itself includes some rather futile dependencies. It's not always possible to be picky, but as a general rule, before adding a new package, it's best to check its dependencies, and if need be to spend some more minutes to look for an available alternative with fewer dependencies. The tools that we propose below were selected this way.

Link between the main process and the user interface

The communication system between the two processes that Electron proposes is an event mechanism which allows you to pass complex structures in an asynchronous way in both directions. But for safety reasons, the user interface process itself is also divided into two distinct contexts:

The Electron architecture
The Electron architecture


This separation is needed so that the code interpreted in the browser has never a direct access to the low level primitives of Node.js, otherwise the simple display of a page taken from an external site would be a major risk. But the result of this dual separation, if we follow the TypeScript integration solution proposed in the Electron documentation, is a multiplication of declarations to define the distinct links in the communication chain. Moreover, this multiplication breaks the type validation chain at compile time, as each context is compiled separately. We are therefore going to propose a Yoctopuce solution which enables you to automatically generate these declarations during the build cycle, thus preserving the type validation chain from start to finish.

Compilation and packaging

In TypeScript, each source file represents a module, with imports and exports. All the modules must thus be compiled into JavaScript and then linked together to enable their use in production in an environment like Electron.

The predominant solution of the moment to do this is called WebPack. WebPack is a little monster, which adds no less than 66 dependencies, and performs not only what we need but also many other things which are not useful for us in the present case, such as ensuring the compatibility with old browsers. We'll therefore use another tool, smaller, simpler, more efficient, and without any dependency.

Then, to distribute the application, the most common solution is to directly build an installer with electron-forge, electron-packager, or electron-builder. These somewhat magical tools, which cost about fifty additional dependencies, are required only when you don't know that you can install an Electron application simply by dropping some files in a directory, or when you don't know how else to do it...

A minimalist, safe, and comfortable solution

As you may have guessed, we propose today a minimalist solution to use Electron in TypeScript, but without compromising comfort and safety of development. We offer you this solution as a boilerplate.

In a few words

To implement the secure link between the main process and the interface, we wrote a small automatic script which analyzes the TypeScript code of the main process and generates the three TypeScript source files required to implement the communication channel. These files are then respectively located in the main code, in the interface pre-loading code, and in the interface code itself. Type annotations are used from start to finish, which allows the editor and the compiler to validate the coherence of the complete chain, rather than causing execution error as it happened with the original solution proposed by Electron.

To compile and to edit the links (bundling) we use esbuild. It's the fastest tool of this kind, without any dependency, which advantageously replaces WebPack in the present case as it does exactly what we need.

We designed two compilation and execution modes. The development mode generates, in the debug/ directory, readable JavaScript code and .map files enabling you to debug in the TypeScript source code. In this mode, you run the locally installed Electron version, and the application automatically detects the changes that you make to the source files and recompiles them on the fly.

The production mode generates minified code and bundles it into dist/resources/app.asar. You can then unzip Electron distributable binaries corresponding to your preferred architecture in the dist/ directory, making sure that the version of the Electron libraries that you use corresponds, and you'll obtain an autonomous application which you can distribute as a zip or with an installer, as any other software.

Last but not least, if you like to define your web interfaces as components with the JSX syntax, enabling you not only to integrate the TypeScript and HTML code, but also to validate the types up to the DOM attributes, this boilerplate is configured to integrate preact, an ultra-light version of React. The example project shows its use on a typical example.

Project structure

You can find the project on GitHub in our example collection. It contains the following directories:

  • build/: Directory for our build tools (written in TypeScript)
  • debug/: Directory where we drop and run files compiled in development mode
  • dist/: Directory where we drop the files compiled in production mode, and where you only need to extract the Electron distributable binaries to obtain an autonomous application
  • src/: Directory containing all the source files of the application
  • src/Main/: Directory specific to the main process
  • src/UI/: Directory specific to the user interface process


Usage

We assume that you have already installed a recent version of Node.js on you machine. To use this project, start by copying the files on your machine and by installing the development dependencies:

npm install


To compile the development version, you can use:

npm run build-dev


But the most convenient is usually to compile and directly run the application in development mode, which includes reloading on the fly each time there is a modification in the source files, with the command:

npm run start-dev


To compile the production version, use:

npm run build-prod


When you have dropped the Electron distributable binaries in the dist/ directory, you can also combine compiling and running the production version with the command:

npm run start-prod


Note however that if you have renamed dist/electron[.exe] to give a more personalized name to the executable, you must change the line defining "start-prod" accordingly in the package.json file.

Finally, if during development you only want to use the TypeScript code generator for communication between the processes, you can also run

npm run generate-api


for a one-time generation, or even

npm run watch-api


to launch a watcher which recompiles the interface each time there is a file change in the Main directory, so that the semantic check of the source codes in your editor happens in real time. Note that this function is embedded in the start-dev command, you therefore don't need to run it in parallel.

Role of each file

As the aim is not to give you a magic tool without explanation, here are details on the role of each source file of the project:

  • src/dev-main.ts: Entry point of the application in development mode, which launches as a background task a process detecting modifications in interface files to reload them on the fly before calling startApplication.
  • src/prod-main.ts: Entry point of the application in production mode, directly launching the startApplication application as soon as the Electron motor is ready.
  • src/tsconfig.json: TypeScript configuration of the application itself, containing the noEmit:true option so that the editor performs semantic checks but not the generation of JavaScript code as we do it with esbuild
  • src/Main/main.ts: Implements the startApplication method, which is going to

    • include mainHandlers to set up the inter-process communication channel
    • create a user interface process starting with src/UI/preload.ts
    • open the interface window on src/UI/App.html
    • run the background tasks defined in src/Main/mainAPI.ts
    • stop the background tasks and exit when the interface is closed.

  • src/Main/mainAPI.ts: File implementing the mainAPI class, where you must put your code for the main task of your application and implement the methods which must be available for the user interface process. You can naturally import from there other modules which you would need to define your main process.
  • src/Main/mainHandlers.ts: Automatically generated file, which stores in the ipcMain object event handlers for methods which must be available to the user interface process.
  • src/UI/App.css: Stylesheet file of the user interface
  • src/UI/App.html: Root file of the user interface loaded by Electron, almost empty since all the interface of our example is defined in TypeScript/JSX. It is copied as-is in the execution directory of the application.
  • src/UI/App.tsx: Actual entry point of the user interface.
    As we decided to create this example with preact, the interface is defined as classes representing components that you can display, and the file ends with a call to the render() function which instantiates the root component in document.body. It is this file that you modify to build your user interface and in which you can import other components that you need. Naturally, if you don't want to use preact and the JSX syntax, you can replace the extension by .ts and put simple TypeScript code.
  • src/UI/preload.ts: Automatically generated file, used as entry point for the user interface process, before it is transferred to the browser. This file is the counterpart of mainHandlers.ts in the user interface process, with which it communicates through the ipcRenderer object. The functions that it defines are made available to the Electron contextBridge gateway.
  • src/UI/preloadAPI.ts: Automatically generated file, which can be included in user interface modules to access selected methods and functions of mainAPI with a typed interface.

To illustrate this, here is the resulting execution and communication flow chart:

Execution and communication flows of the application
Execution and communication flows of the application


We must admit that this is quite a maze, but at least you know exactly the use of each file, and nothing is done behind your back :-)

How the generator works

Apart from the choice of minimalist tools, to which one is free to adhere or not, the main interest of this boilerplate is the small generator of TypeScript code which ensures communication between the main process and the user interface process. As indicated above, this generator creates:

  • the mainHandlers.ts file, imported by main.ts, which stores in the ipcMain object the event handlers required for calls from the user interface process to the main process.
  • the src/UI/preload.ts file, loaded when initializing the BrowserWindow in a privileged context to call the ipcRenderer methods, and to create the secure entry points using Electron contextBridge.
  • the src/UI/preloadAPI.ts file, which defines a typed interface that you can use from all the interface classes to access the functions exposed by the Electron contextBridge.


It's therefore an almost transparent replication for the developer of selected methods in mainAPI, which are exposed identically in the preloadAPI object of the user interface project.

Naturally, the aim is not to expose all the methods of mainAPI, as some of them are not designed to be used by the user interface process and must be excluded, for security reasons. To select the methods which must be replicated, the generator uses the documentation of the functions. All the methods of the mainAPI object preceded by a comment of type "Safe API: function explanation" are considered safe and made available to the user interface process, with the same prototype, in the preloadAPI object. The other methods are considered to be specific to the main process.

A similar mechanism enables the main process to send structured data asynchronously to the use interface process(es). To do so, the mainAPI includes a send method which takes as argument an event name and one or several arguments to be sent. Following the same principle as for calls in the other direction, the generator retrieves in the whole code of mainAPI calls to the mainAPI.send() method, and creates the corresponding typed interface in preloadAPI so that one can subscribe to the reception of these message with a callback. For example, if your implement in the main process the following method:

public log(msg: string): void
{
    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);
}


then the generator adds to the preloadAPI interface the following declarations:

export type LogCallback = (msg: string) => void;
...
export interface PreloadAPI {
        ...
    //  Advertise new log messages
    registerLogCallback(logCallback: LogCallback): UnsubscribeFn
        ...
}


Note that the logCallback identifier is directly derived from the message name which was used with the mainAPI.send method. Likewise, the parameter type(s) of the callback function are determined by the type of the arguments passed as parameters to mainAPI.send.

Obviously, in a real life project, you need to structure your main process with several classes covering different features. The generator can reflect that in the preloadAPI interface as well, on the basis of marking with "Safe API:" comments on the properties susceptible to contain interfaces that need to be replicated. For example, if you define your MainAPI class as follows:

export class MainAPI
{
    // 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();
    }
    ...
}


and that YoctoMonitor, DBAccessor, and Logging classes themselves declare "Safe API:" methods, you find them in the interface on the other side in the following shape:

export interface PreloadAPI {
    //  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: {
        ...
    },
    ...
}


which again enables you to use the user interface process transparently, identically to what you would have done in the main process.

Access to Yoctopuce modules

Now that we have a correct development environment, let's come back to our main goal: implementing in TypeScript an application in its own right using Yoctopuce modules.

Knowing that the application is divided into two processes, the first question to ask oneself is whether the connection to the modules must be performed through the user interface process or through the main process.

If the Yoctopuce modules are used only in the user interface (display module, buzzer, and so on), or for the display in real time of measured values for example, you can select to integrate the Yoctopuce library in the user interface process. In this case, it's one of the files of the src/UI directory that imports yoctolib-esm/yocto_api_html.

On the other hand, if the modules are used for a monitoring task, to save measures in a database, or if the connection to the modules requires a password which must not risk being accessible in the browser by malicious code, it's one of the files of the src/Main directory which must this time import yoctolib-esm/yocto_api_nodejs and drive the module. You can nevertheless then easily share the measures in real time with the user interface process for display, for example by including a call to mainAPI.send() in a value change callback:

temperatureCallback(sensor: YTemperature, value: string)
{
    // Safe API: Advertise current temperature
    mainAPI.send('temperature', parseFloat(value));
}



To give you a concrete illustration, we modified the small example that we had published on this blog two years ago and we brought it up to date, in TypeScript, respecting the security recommendation. The resulting project is available on GitHub in a new example project.

Conclusion

This TypeScript excursion into the world of Electron turned out to be much more expensive to implement than originally imagined. Strange as it may seem, the dozens of Electron example projects in TypeScript that we found on GitHub all suffered from the cancer of dependencies and the lack of true semantic type validation between the main process and the user interface process. In the end, we managed to provide a development environment that met our expectations, but we were far from imagining that we would have to write a small TypeScript code analyzer for that...

We could of course have turned a blind eye to these security issues and run all the code in a single environment combining Node.js and the browser, as it's done in NW.js, or by disabling context isolation in Electron. But apparently, if such care was put into Electron to implement these security barriers, it is because the security breaches due to the promiscuity between node.js and the browser were numerous and severe. The documentation now explicitly states that context isolation has been enabled by default since Electron 12 [March 2021], and is now the recommended security setting for all applications. Including those that think they don't need it. Here's something to justify our efforts at least!

Add a comment No comment yet Back to blog












Yoctopuce, get your stuff connected.