Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sharing code between main and renderer processes #189

Closed
justin-hackin opened this issue Mar 29, 2021 · 8 comments
Closed

Sharing code between main and renderer processes #189

justin-hackin opened this issue Mar 29, 2021 · 8 comments
Assignees
Labels
enhancement New feature or request

Comments

@justin-hackin
Copy link

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
@cawa-93
Copy link
Owner

cawa-93 commented Mar 29, 2021

You are right. Due to the fact that each package technically built independently, there can be difficulties to share modules. In fact, I was thinking about this problem. But I did not find a solution.

Technically you can import anything outside of the root directory. Example:

// packages/shared/constants.ts
export const FOO = 'bar'
// packages/renderer/src/App.vue
import {FOO} from '../../shared/constants'

This works for me, provided that the shared files are easily tree-shaken and have no side effects.

You must understand that such shared files will be imported and executed for each module independently.

And this imposes some limitation:

  • All distributed code will not be made into a common chunk. Instead, it will be duplicated in each package that imports it. Tree shaking will also work independently for each package.
  • Any calculations in runtime will be performed independently. Example:
    // shared/constants.ts
    export const PARAM = Math.random()
    // main/src/index.ts
    import { PARAM } from '../../shared/constants
    console.log(PARAM)
    // preload/src/index.ts
    import { PARAM } from '../../shared/constants
    console.log(PARAM)
    Will be compiled to:
    // main/dist/index.cjs
    const PARAM = Math.random();
    console.log(PARAM);
     // preload/dist/index.cjs
    const PARAM = Math.random();
    console.log(PARAM);
    Note that Math.random(); is called separately for each package. Therefore, you cannot easily share one value between them.
  • You cannot share Symbol() between packages.
  • Side effects can lead to unpredictable behavior.

You could explore how this repo works. As far as I know, the author used a plugin-based approach. Maybe this will help you.

@wuzzeb
Copy link

wuzzeb commented 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.

@haikyuu
Copy link

haikyuu commented May 28, 2021

@wuzzeb Thanks a lot. It works perfectly fine.

@justin-hackin
Copy link
Author

@cawa-93 and @wuzzeb thanks for your detailed responses, excuse the delayed response. As I prepare to release my app into the wild, I've been thinking about security and I want to remove the nodeIntegration: true requirement as discussed here electron-userland/electron-webpack#392 . It looks like it's impossible to truly share code with this build setup but the code duplication for me is minimal and these solutions look decent so I'll close this.

@justin-hackin
Copy link
Author

@wuzzeb I might try using contextIsolation bridge instead in order to create typesafety for ipc stuff

@wuzzeb
Copy link

wuzzeb commented Sep 16, 2021

The main difference is the context bridge is synchronous while using message channels as I describe above is asynchronous and you can await the response. With my solution it is nice to be able to just await sendIpc("SomeMessage", ...) and have it resolve when the operation completes.

I guess it depends on what kinds of things you are doing in the main process, if it is just things like file open, you probably don't care that the renderer process is frozen while the synchronous call to the main process happens.

@kevinfrei
Copy link

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 kevinfrei/EMP@0c1af40

@pukmajster
Copy link

pukmajster commented Dec 17, 2022

@kevinfrei such a simple and neat solution, thanks mate!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

6 participants