UNPKG

5.08 kBPlain TextView Raw
1import uuid from 'uuid/v4';
2import { serializeError } from 'serialize-error';
3import type { IpcMain, IpcRenderer, WebContents, IpcMainEvent, IpcRendererEvent } from 'electron';
4import 'object.entries/auto'; // Shim Object.entries. Required to use serializeError.
5
6type IpcEvent = IpcRendererEvent & IpcMainEvent;
7
8/**
9 * For backwards compatibility, event is the (optional) LAST argument to a listener function.
10 * This leads to the following verbose overload type for a listener function.
11 */
12export type Listener =
13 | { (event?: IpcEvent): void }
14 | { (arg1?: unknown, event?: IpcEvent): void }
15 | { (arg1?: unknown, arg2?: unknown, event?: IpcEvent): void }
16 | { (arg1?: unknown, arg2?: unknown, arg3?: unknown, event?: IpcEvent): void }
17 | {
18 (
19 arg1?: unknown,
20 arg2?: unknown,
21 arg3?: unknown,
22 arg4?: unknown,
23 event?: IpcEvent,
24 ): void;
25 }
26 | {
27 (
28 arg1?: unknown,
29 arg2?: unknown,
30 arg3?: unknown,
31 arg4?: unknown,
32 arg5?: unknown,
33 event?: IpcEvent,
34 ): void;
35 };
36export type Options = { maxTimeoutMs?: number };
37// There's an `any` here it's the only way that the typescript compiler allows you to call listener(...dataArgs, event).
38// eslint-disable-next-line @typescript-eslint/no-explicit-any
39type WrappedListener = { (event: IpcEvent, replyChannel: string, ...dataArgs: any[]): void };
40
41export default class PromiseIpcBase {
42 private eventEmitter: IpcMain | IpcRenderer;
43
44 private maxTimeoutMs: number;
45
46 private routeListenerMap: Map<string, Listener>;
47
48 private listenerMap: Map<Listener, WrappedListener>;
49
50 constructor(opts: { maxTimeoutMs?: number } | undefined, eventEmitter: IpcMain | IpcRenderer) {
51 if (opts && opts.maxTimeoutMs) {
52 this.maxTimeoutMs = opts.maxTimeoutMs;
53 } // either ipcRenderer or ipcMain
54
55 this.eventEmitter = eventEmitter;
56 this.routeListenerMap = new Map();
57 this.listenerMap = new Map();
58 }
59
60 public send(
61 route: string,
62 sender: WebContents | IpcRenderer,
63 ...dataArgs: unknown[]
64 ): Promise<unknown> {
65 return new Promise((resolve, reject) => {
66 const replyChannel = `${route}#${uuid()}`;
67 let timeout: NodeJS.Timeout;
68 let didTimeOut = false; // ipcRenderer will send a message back to replyChannel when it finishes calculating
69
70 this.eventEmitter.once(
71 replyChannel,
72 (event: IpcEvent, status: string, returnData: unknown) => {
73 clearTimeout(timeout);
74 if (didTimeOut) {
75 return null;
76 }
77 switch (status) {
78 case 'success':
79 return resolve(returnData);
80 case 'failure':
81 return reject(returnData);
82 default:
83 return reject(new Error(`Unexpected IPC call status "${status}" in ${route}`));
84 }
85 },
86 );
87 sender.send(route, replyChannel, ...dataArgs);
88 if (this.maxTimeoutMs) {
89 timeout = setTimeout(() => {
90 didTimeOut = true;
91 reject(new Error(`${route} timed out.`));
92 }, this.maxTimeoutMs);
93 }
94 });
95 }
96
97 public on(route: string, listener: Listener): PromiseIpcBase {
98 const prevListener = this.routeListenerMap.get(route); // If listener has already been added for this route, don't add it again.
99 if (prevListener === listener) {
100 return this;
101 } // Only one listener may be active for a given route. // If two are active promises it won't work correctly - that's a race condition.
102 if (this.routeListenerMap.has(route)) {
103 this.off(route, prevListener);
104 } // This function _wraps_ the listener argument. We maintain a map of // listener -> wrapped listener in order to implement #off().
105 const wrappedListener: WrappedListener = (event, replyChannel, ...dataArgs): void => {
106 // Chaining off of Promise.resolve() means that listener can return a promise, or return
107 // synchronously -- it can even throw. The end result will still be handled promise-like.
108 Promise.resolve()
109 .then(() => listener(...dataArgs, event))
110 .then((results) => {
111 event.sender.send(replyChannel, 'success', results);
112 })
113 .catch((e) => {
114 event.sender.send(replyChannel, 'failure', serializeError(e));
115 });
116 };
117 this.routeListenerMap.set(route, listener);
118 this.listenerMap.set(listener, wrappedListener);
119 this.eventEmitter.on(route, wrappedListener);
120 return this;
121 }
122
123 public off(route: string, listener?: Listener): void {
124 const registeredListener = this.routeListenerMap.get(route);
125 if (listener && listener !== registeredListener) {
126 return; // trying to remove the wrong listener, so do nothing.
127 }
128 const wrappedListener = this.listenerMap.get(registeredListener);
129 this.eventEmitter.removeListener(route, wrappedListener);
130 this.listenerMap.delete(registeredListener);
131 this.routeListenerMap.delete(route);
132 }
133
134 public removeListener(route: string, listener?: Listener): void {
135 this.off(route, listener);
136 }
137}