question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Sharing code between main and renderer processes

See original GitHub issue

Is your feature request related to a problem? Please describe. This repository is posited as an alternative to electron-webpack as the library is now in “maintenance mode” as stated in readme.

In electron-webpack, is it possible to share resources between main and renderer processes. This allows for best practices such as avoiding the hard-coding of event name strings for IPC communication via constants. In this case, common code resources are located in a folder which is a sibling to main and renderer folders.

Describe the solution you’d like This repo should include configuration that enables sharing of code between main and renderer builds.

Describe alternatives you’ve considered I’ve attempted to use the solution posited in this issue but this causes typescript errors. Aliases present DX issues because one can’t use regular import statements like in webpack and must resort to some kind of module resolver which is beyond the scope of someone who wants to get started on an electron project quickly and easily.

Another solution I’ve attempted: move index.html file directly inside packages folder, setting the PACKAGE_ROOT to be packages folder. For renderer, main, and preload folders, in vite config files, change build.lib.entry value to be “[folderName]/src/index.ts” and change index.html script src to be “./renderer/src/index.tsx”. This does not fix the issue and the watch tasks errors out:

Error: Cannot find module '../../common/constants'
Require stack:
- /home/user/code/polyhedral-net-factory/packages/main/dist/index.cjs
- /home/user/code/polyhedral-net-factory/node_modules/electron/dist/resources/default_app.asar/main.js

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Comments:8 (1 by maintainers)

github_iconTop GitHub Comments

6reactions
wuzzebcommented, Apr 21, 2021

I’d just like to explain how I share type-safe IPC between render and main process. Unfortunately, I don’t have a nice template repo like this to share, so I’ll just show code fragments.

First, I have a packages/api project which only contains type definitions, no code. In the packages/api project, I define interfaces for the various things that will be sent between renderer and main and then define

type IpcRequestResponse =
  | { type: "FileAccess_OpenFile", request: null, response: TheFileData }
  | { type: "FileAccess_SaveFile", request: { data: TheFileData, saveAs: boolean }, response: { saveCanceled: boolean }
  | ...

Each request or response defines both the request type and the response type (TheFileData is an interface for the contents of the file.)

I then define

type MessageTypes<A> = A extends { type: infer T } ? T : never;
type IpcRequest<A, T> = A extends { type: T; request: infer R } ? R : never;
type IpcResponse<A, T> = A extends { type: T; response: infer R } ? R : never;

export type ProjectNameIpcMessageTypes = MessageTypes<IpcRequestResponse>;
export type ProjectNameIpcRequest<T extends ProjectNameIpcMessageTypes> = IpcRequest<
  IpcRequestResponse,
  T
>;
export type ProjectNameIpcResponse<T extends ProjectNameIpcMessageTypes> = IpcResponse<
  IpcRequestResponse,
  T
>;

This automatically pulls out the message types, the request type for a specific message, and the response type for a specific message. You get compile errors if you try and get the request type of a message that does not exist.


In packages/main I define the handlers for each message as follows. First, define

type MessageHandlers = {
  [key in ProjectNameIpcMessageTypes]: (
    window: BrowserWindow,
    request: ProjectNameIpcRequest<key>
  ) => Promise<ProjectNameIpcResponse<key>>;
};

const handlers: MessageHandlers = {
  FileAccess_OpenFile: async (window, req) => {
    const file = await dialog.showOpenDialog(window, { title: ..... });
    ...
    return { theFileData: ... }
  },
  FileAccess_SaveFile: async (window, req) => { ... },
  ...
}

What is nice is that you get a compile error if you forget one of the types (MessageHandlers specifies [key in ProjectNameIpcMessageTypes] which requires a key for each message. Also, the type of the req parameter is automatically inferred and you get a compile error if you return the wrong response. For large handlers, I move the handler function out into its own file and import it when creating the handlers object.


Since the handlers are in main instead of preload, I need to bridge main and preload which I do using MessageChannelMain.

In main, I have code like

  const window = new BrowserWindow(...);

  const { port1, port2 } = new MessageChannelMain();

  port1.on("message", (msgEvt) => {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    const message: { id: unknown; type: ProjectNameIpcMessageTypes; request: unknown } =
      msgEvt.data;
    // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
    ((handlers as any)[message.type](window, message.request) as Promise<any>)
      .then((response) => {
        port1.postMessage({
          id: message.id,
          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
          response: response,
        });
      })
      .catch((err) => {
        if (err instanceof Error) {
          err = err.message;
        }
        port1.postMessage({
          id: message.id,
          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
          error: err,
        });
      });
  });
  port1.start();

  window.webContents.on("did-finish-load", () => {
    window.webContents.postMessage("projectname-init", "", [port2]);
  });

Each message is an object containing an id, the type, and the request. We then lookup the handler in the handlers object, make the call, and send a response with the same id.

For port2, it is sent to the renderer process once the did-finish-load event occurs, which means the renderer has had time to attach an event handler for the projectname-init.


The preload script is very short because all the work is done in main and the renderer. It just shuffles the projectnameInit message along to the renderer, passing along the port. This is the entierty of my preload.js which I don’t even bother to write in typescript or compile.

const { ipcRenderer } = require("electron");

ipcRenderer.once("projectname-init", (evt, msg) => {
  window.postMessage({ projectnameInit: true, msg }, "*", evt.ports);
});

Finally, the renderer contains code to send and receive messages over the port using the specific api types imported from packages/api.

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inFlight = new Map<number, (response: any) => void>();
let nextId = 0;

let resolvePort: (p: MessagePort) => void;
const portPromise: Promise<MessagePort> = new Promise((resolve) => (resolvePort = resolve));

function handleProjectInit(event: MessageEvent<{ projectnameInit: boolean }>) {
  if (event.source === window && typeof event.data === "object" && event.data && event.data.projectnameInit) {
    resolvePort(event.ports[0]);
    event.ports[0].onmessage = (portMsg) => {
      const msg = portMsg.data;
      const handler = inFlight.get(msg.id);
      if (handler) {
        handler(msg);
      }
    };
    window.removeEventListener("message", handleProjectInit);
  }
}

window.addEventListener("message", handleProjectInit);

export function sendIpc<T extends ProjectNameIpcMessageTypes>(ty: T, request: ProjectNameIpcRequest<T>): Promise<ProjectNameIpcResponse<T>> {
  const messageId = nextId;
  nextId += 1;
  return portPromise.then(
    (port) =>
      new Promise((resolve, reject) => {
        inFlight.set(messageId, (response) => {
          inFlight.delete(messageId);
          if (response.error) {
            reject(response.error);
          } else {
            resolve(response.response);
          }
        });
        port.postMessage({
          id: messageId,
          type: ty,
          request: request,
        });
      })
  );
}

Each mesage has an incrementing id and the inFlight map stores message id to the response function. The handleProjectInit function listens for the message from preload with the port for communication. It resolves the port and attaches a handler to messages coming over the port. When a message arrives on the port, it looks up the handler in inFlight by message id.

The sendIpc function, which is the main export, creates a promise which first waits for the port. (This allows code to call sendIpc before the port from main arrives and correctly suspends them.) sendIpc then sets up the response in the inFlight map and then sends the request across the port.

Finaly, sendIpc is typed so that you can only call it with a message type defined in projects/api. VSCode even pops up the suggestion box with a list of messages. The request and response are then inferred from the specified message type. Since sendIpc returns a promise, it can be awaited everywhere throughout the renderer.

4reactions
kevinfreicommented, Jan 2, 2022

For sharing actual code I just use a “local” dependency. It’s a little klunky because you need to do “yarn install” when you make changes to the common code, and it gets duplicated across the two parts of the code, but it’s nice for things like shared string ID’s. You can see it in action if you want right here https://github.com/kevinfrei/EMP/commit/0c1af4026a0c254157dcc0a4437e854873fa9626

Read more comments on GitHub >

github_iconTop Results From Across the Web

How do you share code between main and renderer processes? : r ...
I'm struggling to find a good project setup that will let me share code between main and renderer processes. At best I can...
Read more >
Interop's Labyrinth: Sharing Code Between Web & Electron ...
The main process bootstraps the app and coordinates other processes in the background, while the renderer process is responsible for what the user...
Read more >
Electron: Share constants file between both main/renderer ...
Electron w/ Typescript: Share a constants.ts file between both the main and renderer process. Folder Structure: src/. main. main.ts.
Read more >
Deep dive into Electron's main and renderer processes
IPC can work between renderers and the main process in both directions. IPC is asynchronous by default but also has synchronous APIs (like...
Read more >
Inter-Process Communication - Electron
To fire a one-way IPC message from a renderer process to the main process, you can use the ipcRenderer.send API to send a...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found