DEV Community

Cover image for The architecture of an Electron app ported to Web
Wardell Bagby
Wardell Bagby

Posted on • Updated on

The architecture of an Electron app ported to Web

Hey, y'all! I'm Wardell! In my free time (and when I should be sleeping), I hack on my app, Lyricistant. Buckle up, 'cause this is going to be a long one.


So, what is Lyricisant?

Lyricistant, at face value, is a very simple app. Its own tag line is this:

Lyricistant is a writing app geared toward helping you write lyrics, poetry, or anything else you desire!

I made Lyricistant to help myself write lyrics for my own music, but in true developer fashion, I've kept "just one more feature"-ing it until it ballooned into something much more than what I set out to make.

Going to its website, most of what you see is a big text area, prompting you to type out your lyrics. Typing in some text will show a list of rhymes related to the word near your cursor.

Pretty simple, right? And yet, it'll be reaching its 3rd year of active development in February of 2022. It's also on track to easily surpass 500 commits in 2022. How does something with such a simple purpose still not end up finished after all this time?

To be fair, the length of time a project has been alive and its number of commits isn't a fair measure of the complexity of the project, but still. It's a glorified <textarea> and <ul> with a little bit of Javascript to tie it all together! It's a bit silly, isn't it?

There are a lot of reasons Lyricistant is so complex, but today, we're going to talk about one big one: Lyricistant, which started as an Electron app, now builds both a fully usable offline Electron app and a website from the same codebase. Plus a couple other apps we aren't going to talk about today. The coolest part, though?

The UI code has absolutely no idea... 🤫


Electron is just Chrome + Node. What's the big deal?

I thought the same thing when I first chose Electron for Lyricistant. I'd just write my UI code like how I would for a website, but with the benefit of being able to use Node whenever I want!

That Node pitfall is one of ways that Electron traps ya, though!

Once you start relying on Node, it's definitely not easy to stop. This is doubly true for an app that primarily deals with reading and writing files; there's no easy translation from fs.readFile to <input type="file">.

Outside of Node, there's another big gotcha: Electron has two processes working in tandem, and you communicate between them using only data that is supported by the structured clone algorithm. You can send primitive data, object literals, arrays, and...not much else.

The main process lets you do all your fancy Node stuff; the renderer process runs your UI code.

Communication in a normal Electron app looks something like this:

// in Electron's main process
browserWindow.webContents.send('data-from-main', {hello: 'world'})
ipcMain.on('data-from-renderer', (event, data) => {
  console.log(data.kendrick) // logs 'here'
}
Enter fullscreen mode Exit fullscreen mode
// in Electron's renderer process (i.e., a BrowserWindow)
ipcRenderer.on('data-from-main', (event, data) => {
  console.log(data.hello); // logs 'world'
  ipcRenderer.send('data-from-renderer', {kendrick: 'here'});
});
Enter fullscreen mode Exit fullscreen mode

Imagine you've got a codebase with a ton of those listeners thrown around, and you'll be able to feel the pain I went through when I decided to port Lyricistant to the web.


Alright, I think I'm understanding your struggles. How'd you get rid of all those listeners?

Well....I didn't!

Electron's inter-process communication (aka IPC) is only bad because it's coupled so closely to Electron. The UI code has to know that it's running on Electron so that it knows how to communicate with the main process.

But, there's nothing stopping us from wrapping Electron's IPC with our own, Electron-agnostic logic. Which is exactly what I did.

Enter the Delegate.

export interface Delegate {
  send(channel: string, ...args: any[]): void;
  on(channel: string, listener: (...args: any[]) => void): void;
}
Enter fullscreen mode Exit fullscreen mode

When running on Electron, we provide the UI code with a Delegate that delegates to Electron's ipcMain to communicate between the two processes. Lyricistant calls this object a Platform Delegate.

We also provide the code running in Electron's main process a similar object called the Renderer Delegate. When the UI uses the Platform Delegate to send data, main process code gets that data via its Renderer Delegate. The main process code can also use the Renderer Delegate to send data to the UI.

When running on Web, though...


Wait wait wait. Yes, you can wrap Electron's IPC but browsers still only give you one process... You've got nothing to send data to.

Web Workers would like to talk to you, but that's actually not what I did initially.

Even with a single process, we can emulate Electron's two process model by just calling listeners ourselves!

class WebPlatformDelegate {
  getListeners(channel: string) {
    // return the listeners set on `on`
  }

  send(channel: string, args: any[]) {
    rendererDelegate.getListeners(channel)
      .forEach((listener) => listener(...args));
  }

  on(channel: string, listener: (...args: any[]) => void) {
    addListener(channel, listener);
  }
}

class WebRendererDelegate {
  getListeners(channel: string) {
    // return the listeners set on `on`
  }

  send(channel: string, args: any[]) {
    platformDelegate.getListeners(channel)
      .forEach((listener) => listener(...args));
  }

  on(channel: string, listener: (...args: any[]) => void) {
    addListener(channel, listener);
  }
}
Enter fullscreen mode Exit fullscreen mode

Okay, you've successfully emulated Electron's two processes in a single process. But how do you solve the no-Node problem?

Let's talk about inversion of control. If you're an Android developer like me, think dependency injection, like Dagger. If you're a React developer, think of useContext. If you're a Node developer, think of the service locator pattern.

Regardless of the pattern, the idea is the same; we decouple the platform-specific code from the platform-agnostic code.

Let's use saving a new file as an example. When the user wants to save a new file, Lyricistant needs to do quite a few things!

When running on Electron:

  1. Get the current text that's displayed on the UI.
  2. Show a dialog to let the user pick where to save the file.
  3. Save the data to the file the user picked.
  4. Store the path to the file so that the user won't need to pick it again if they hit save again.

When running on Web:

  1. Get the current text that's displayed on the UI.
  2. Create an <a> tag with a URL to a blob of the current text as its href.
  3. Programmatically click the <a> tag.
  4. Let the browser save the file.

Step 1 is platform-agnostic. It doesn't matter if it's the Electron version or the Web version; the logic for that step is exactly the same. The rest of the steps are platform-specific.

We can use inversion of control to decouple those steps away from the "common" steps. Lyricistant forces its platforms to provide implementations for steps that are platform-specific. For file saving, it looks something like this:

interface FileMetadata {
  /**
   * A unique way of identifying a file, dependent on the platform in question. For instance, against a Desktop
   * platform, we would expect this to be a file path, such as "/Desktop/myfile.txt". On Android, we might expect this
   * to be a content URI, such as "content://documents/1".
   */
  path: string;
  /**
   * A human displayable name that refers to this file in question. If the path can double as a human displayable name,
   * this can be omitted.
   */
  name?: string;
}

interface Files {
  saveFile: (data: ArrayBuffer, path?: string) => Promise<FileMetadata>;
}
Enter fullscreen mode Exit fullscreen mode

Using the Files interface, once Lyricistant has retrieved the text from the UI, it will send that text as an ArrayBuffer to the implementation of Files it had injected, along with any path it might have (remember Electron's step 4?) so that the Files implementation can do the work of saving the file.

By following this pattern, we can do all sorts of stuff without ever knowing for sure what platform we're running on!


Hmm. Okay, but what about those common steps?

Good point.

We also use inversion of control to handle that! Those common steps can be shared across multiple platforms, but the platforms themselves don't really need to know what's handling those steps.

Enter the idea of a Manager. Simple name, I know. Simple interface, too.

interface Manager {
  register(): void;
}
Enter fullscreen mode Exit fullscreen mode

The idea is that we group common functionality into a Manager, and that manager will register listeners on the Renderer Delegate, inject various platform-specific classes to handle platform-specific logic, and send data back to the UI via the Renderer Delegate.

Lyricistant has a few Managers: FileManager, and PreferencesManager to name a couple. Those are core to Lyricistant functionality; you always need to open a file and manage your preferences, no matter the platform.

All platforms need to do is set the list of managers they need and then call register on those managers when the platform starts up. They get all the common code for free. No need to worry about storing the current file, or loading any data the user might not have saved the last time they closed Lyricistant.

However, there's some functionality that only matters to certain platforms. For instance, there's a QuitManager that only Electron uses to manage prompting the user when they attempt to quit the app. It's allowed to use Electron-specific functionality because it's not stored with the rest of the common code. Electron adds the QuitManager to its list of managers, it gets registered with its manager friends, and gets to do its job only for Electron. Web has no idea it exists.


Can I get a tl;dr? That was a lot!

Sure!

There's 3 "types" of code in Lyricistant.

  • UI code (e.g., the React code that renders the UI)
  • Platform-specific code (e.g., Electron code that uses Node to save a file, like Files)
  • Platform-agnostic code (e.g., code that handles coordinating between the UI and the platform-specific code, like the Managers).

In Electron, platform-specific code and platform-agnostic code all run in the main process. UI code runs in the renderer process.

In Web, all code runs in the same process. (At least for now! Be on the lookout for the next Lyricistant release!)


Wow, that's a lot of architecture just to build an Electron app and a website from the same codebase.

You're telling me! It's not perfect by any means, and this isn't even how it is right now; there's a lot of tweaks to this in the repo that haven't been pushed to a new release yet, but the ideas are still very much the same.

I don't think everyone would need to do something as heavy as this; a lot of apps will probably work just fine by doing platform checks in their UI code. Even more would be fine by just solely using browser APIs and not relying on Node at all. Lyricistant is a bit special, mostly because I'm the one who writes it and I like to be more complicated than necessary. 🙃

Even factoring that in, I hope that some of this was interesting, or at least a little helpful to those of you who might be considering creating an Electron app!

GitHub logo wardellbagby / lyricistant

A helpful writing assistant for lyricists!

Top comments (0)