@kabacorp/kaba-electron-rpc 7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/index.js +2 -0
- package/lib/export-api.js +351 -0
- package/lib/import-api.js +291 -0
- package/lib/util.js +77 -0
- package/package.json +30 -0
- package/test/api.js +300 -0
- package/test/index.js +30 -0
- package/test/main-runner.js +437 -0
- package/test/manifest.js +55 -0
- package/test/renderer-runner.html +36 -0
- package/test/renderer-runner.js +519 -0
- package/test/webview-readable.js +5 -0
- package/test/webview-runner.html +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# kaba-electron-rpc
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
|
|
5
|
+
- Supports RPC calls to/from the renderer or webview or a node child-process to the background process
|
|
6
|
+
- Supports methods which return:
|
|
7
|
+
- Sync values
|
|
8
|
+
- Async CBs
|
|
9
|
+
- Promises
|
|
10
|
+
- Readable streams
|
|
11
|
+
- Writable streams
|
|
12
|
+
- Duplex streams
|
|
13
|
+
- Permissions by examining the sender of the call
|
|
14
|
+
- Monitors renderer/webview lifetime to automatically release streams
|
|
15
|
+
- Optional timeout for async methods
|
|
16
|
+
|
|
17
|
+
## Example usage
|
|
18
|
+
|
|
19
|
+
In a shared `example-api-manifest.js`:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
module.exports = {
|
|
23
|
+
// simple method-types
|
|
24
|
+
readFile: 'async',
|
|
25
|
+
readFileSync: 'sync',
|
|
26
|
+
sayHello: 'promise',
|
|
27
|
+
createReadStream: 'readable',
|
|
28
|
+
createWriteStream: 'writable',
|
|
29
|
+
createDuplexStream: 'duplex'
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
In the main electron process:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
var rpc = require('kaba-electron-rpc')
|
|
37
|
+
var manifest = require('./example-api-manifest')
|
|
38
|
+
var fs = require('fs')
|
|
39
|
+
|
|
40
|
+
// export over the 'example-api' channel
|
|
41
|
+
var api = rpc.exportAPI('example-api', manifest, {
|
|
42
|
+
// the exported API behaves like normal calls:
|
|
43
|
+
readFile: fs.readFile,
|
|
44
|
+
readFileSync: fs.readFileSync,
|
|
45
|
+
sayHello: () => return Promise.resolve('hello!'),
|
|
46
|
+
createReadStream: fs.createReadStream,
|
|
47
|
+
createWriteStream: /* ... */,
|
|
48
|
+
createDuplexStream: /* ... */
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// log any errors
|
|
52
|
+
api.on('error', console.log)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
In the renderer or webview process:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
var rpc = require('kaba-electron-rpc')
|
|
59
|
+
var manifest = require('./example-api-manifest')
|
|
60
|
+
|
|
61
|
+
// import over the 'example-api' channel
|
|
62
|
+
var api = rpc.importAPI('example-api', manifest, { timeout: 30e3 })
|
|
63
|
+
|
|
64
|
+
// now use, as usual:
|
|
65
|
+
api.readFileSync('/etc/hosts') // => '...'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
### rpc.exportAPI(channelName, manifest, methods, [globalPermissionCheck])
|
|
71
|
+
|
|
72
|
+
Methods will be called with a `this` set to the `event` object from [electron ipc](http://electron.atom.io/docs/api/ipc-main/#event-object).
|
|
73
|
+
Don't touch `returnValue`.
|
|
74
|
+
|
|
75
|
+
You can optionally specify a method for `globalPermissionCheck` with the following signature:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
function globalPermissionCheck (event, methodName, args) {
|
|
79
|
+
if (event.sender.getURL() != 'url-I-trust') return false
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If `globalPermissionCheck` is specified, and does not return true, the method call will respond with a 'Denied' error.
|
|
85
|
+
|
|
86
|
+
### rpc.importAPI(channelName, manifest [,options])
|
|
87
|
+
|
|
88
|
+
- `options.timeout` Number. Specify how long in ms that async methods wait before erroring. Set to `false` to disable timeout.
|
|
89
|
+
- `options.errors` Object. Provides custom error constructors.
|
|
90
|
+
- `options.wc` WebContents. The web-contents that is exporting the API. Use this when importing an API from a webContents into the main thread.
|
|
91
|
+
- `options.proc` ChildProcess. The child-process that is exporting the API. Use this when importing an API from a node child process into the main thread.
|
|
92
|
+
|
|
93
|
+
## Readable Streams
|
|
94
|
+
|
|
95
|
+
Readable streams in the clientside are given a `.close()` method.
|
|
96
|
+
All serverside streams MUST implement `.close()` or `.destroy()`, either of which will be called.
|
|
97
|
+
|
|
98
|
+
Stream methods can return a promise that resolves to a stream.
|
|
99
|
+
|
|
100
|
+
## Buffers and ArrayBuffers
|
|
101
|
+
|
|
102
|
+
Arguments and return values are massaged so that they are Buffers on the exporter's side, and ArrayBuffers on the importer side.
|
|
103
|
+
|
|
104
|
+
## Custom Errors
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
// shared code
|
|
108
|
+
// =
|
|
109
|
+
|
|
110
|
+
var manifest = {
|
|
111
|
+
testThrow: 'promise'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class MyCustomError extends Error {
|
|
115
|
+
constructor() {
|
|
116
|
+
super()
|
|
117
|
+
this.name = 'MyCustomError'
|
|
118
|
+
this.message = 'Custom error!'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// server
|
|
123
|
+
// =
|
|
124
|
+
|
|
125
|
+
rpc.exportAPI('error-api', manifest, {
|
|
126
|
+
testThrow() {
|
|
127
|
+
return Promise.reject(new MyCustomError())
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// client
|
|
132
|
+
// =
|
|
133
|
+
|
|
134
|
+
var rpcClient = rpc.importAPI('error-api', manifest, {
|
|
135
|
+
errors: {MyCustomError} // pass in custom error constructors
|
|
136
|
+
})
|
|
137
|
+
rpcClient.testThrow().catch(console.log) // => MyCustomError
|
|
138
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
const EventEmitter = require("events");
|
|
2
|
+
const {
|
|
3
|
+
IPCValueToValue,
|
|
4
|
+
valueToIPCValue,
|
|
5
|
+
isNodeProcess,
|
|
6
|
+
isRenderer,
|
|
7
|
+
} = require("./util");
|
|
8
|
+
|
|
9
|
+
const _ = require("lodash");
|
|
10
|
+
|
|
11
|
+
module.exports = function (
|
|
12
|
+
channelName,
|
|
13
|
+
manifest,
|
|
14
|
+
methods,
|
|
15
|
+
globalPermissionCheck
|
|
16
|
+
) {
|
|
17
|
+
var api = new EventEmitter();
|
|
18
|
+
var webcontentsStreams = {};
|
|
19
|
+
|
|
20
|
+
var channel;
|
|
21
|
+
if (isNodeProcess()) {
|
|
22
|
+
var mockedSender = new EventEmitter();
|
|
23
|
+
mockedSender.id = "main-process";
|
|
24
|
+
mockedSender.send = (channelName, msgType, requestId, ...args) => {
|
|
25
|
+
// console.log('export-api#send', {channelName, msgType, requestId, args})
|
|
26
|
+
process.send({ channelName, msgType, requestId, args });
|
|
27
|
+
};
|
|
28
|
+
channel = {
|
|
29
|
+
onMessage(cb) {
|
|
30
|
+
process.on("message", (msg) => {
|
|
31
|
+
if (msg.channelName !== channelName) return;
|
|
32
|
+
let mockedEvent = { sender: mockedSender };
|
|
33
|
+
// console.log('export-api#onMessage', msg)
|
|
34
|
+
cb(mockedEvent, msg.methodName, msg.requestId, ...msg.args);
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
} else if (isRenderer()) {
|
|
39
|
+
var { ipcRenderer } = require("electron");
|
|
40
|
+
channel = {
|
|
41
|
+
onMessage(cb) {
|
|
42
|
+
ipcRenderer.on(channelName, cb);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
} else {
|
|
46
|
+
var { ipcMain } = require("electron");
|
|
47
|
+
channel = {
|
|
48
|
+
onMessage(cb) {
|
|
49
|
+
ipcMain.on(channelName, cb);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// wire up handler
|
|
55
|
+
channel.onMessage(async function (event, methodName, requestId, ...args) {
|
|
56
|
+
// console.log('received', channelName, methodName, requestId, ...args)
|
|
57
|
+
args = args.map(IPCValueToValue);
|
|
58
|
+
|
|
59
|
+
// watch for a navigation event
|
|
60
|
+
var hasNavigated = false;
|
|
61
|
+
function onDidNavigate() {
|
|
62
|
+
hasNavigated = true;
|
|
63
|
+
}
|
|
64
|
+
event.sender.on("did-navigate", onDidNavigate);
|
|
65
|
+
|
|
66
|
+
// helper to send
|
|
67
|
+
const send = function (
|
|
68
|
+
msgType,
|
|
69
|
+
err,
|
|
70
|
+
value,
|
|
71
|
+
keepListeningForDidNavigate = false
|
|
72
|
+
) {
|
|
73
|
+
if (event.sender.isDestroyed && event.sender.isDestroyed()) return; // dont send response if destroyed
|
|
74
|
+
if (!keepListeningForDidNavigate)
|
|
75
|
+
event.sender.removeListener("did-navigate", onDidNavigate);
|
|
76
|
+
if (hasNavigated) return; // dont send response if the page changed
|
|
77
|
+
|
|
78
|
+
console.log("sending", channelName, msgType, requestId, err, value);
|
|
79
|
+
//
|
|
80
|
+
if (event.reply) {
|
|
81
|
+
var v = _.cloneDeep(value);
|
|
82
|
+
event.sender.send(channelName, msgType, requestId, err, v);
|
|
83
|
+
// event.reply(channelName, msgType, requestId, err, value);
|
|
84
|
+
} else if (event.sender && event.sender.send) {
|
|
85
|
+
var v = _.cloneDeep(value);
|
|
86
|
+
event.sender.send(channelName, msgType, requestId, err, v);
|
|
87
|
+
// event.sender.send(channelName, msgType, requestId, err, value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// handle special methods
|
|
92
|
+
if (methodName == "stream-request-write") {
|
|
93
|
+
event.returnValue = true;
|
|
94
|
+
return streamRequestWrite(event.sender.id, requestId, args);
|
|
95
|
+
}
|
|
96
|
+
if (methodName == "stream-request-end") {
|
|
97
|
+
event.returnValue = true;
|
|
98
|
+
return streamRequestEnd(event.sender.id, requestId, args);
|
|
99
|
+
}
|
|
100
|
+
if (methodName == "stream-request-close") {
|
|
101
|
+
event.returnValue = true;
|
|
102
|
+
return streamRequestClose(event.sender.id, requestId, args);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// look up the method called
|
|
106
|
+
var type = manifest[methodName];
|
|
107
|
+
var method = methods[methodName];
|
|
108
|
+
if (!type || !method) {
|
|
109
|
+
api.emit(
|
|
110
|
+
"error",
|
|
111
|
+
new Error(`Method not found: "${methodName}"`),
|
|
112
|
+
arguments
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// global permission check
|
|
118
|
+
if (
|
|
119
|
+
globalPermissionCheck &&
|
|
120
|
+
!globalPermissionCheck(event, methodName, args)
|
|
121
|
+
) {
|
|
122
|
+
// repond according to method type
|
|
123
|
+
if (type == "async" || type == "promise") {
|
|
124
|
+
send("async-reply", "Method Access Denied");
|
|
125
|
+
} else {
|
|
126
|
+
event.returnValue = { error: "Method Access Denied" };
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// run method by type
|
|
132
|
+
if (type == "sync") {
|
|
133
|
+
// call sync
|
|
134
|
+
try {
|
|
135
|
+
event.returnValue = {
|
|
136
|
+
success: valueToIPCValue(method.apply(event, args)),
|
|
137
|
+
};
|
|
138
|
+
} catch (e) {
|
|
139
|
+
event.returnValue = { error: e.message };
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (type == "async") {
|
|
144
|
+
// create a reply cb
|
|
145
|
+
const replyCb = (err, value) => {
|
|
146
|
+
if (err) err = errorObject(err);
|
|
147
|
+
send("async-reply", err, valueToIPCValue(value));
|
|
148
|
+
};
|
|
149
|
+
args.push(replyCb);
|
|
150
|
+
|
|
151
|
+
// call async
|
|
152
|
+
method.apply(event, args);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (type == "promise") {
|
|
156
|
+
// call promise
|
|
157
|
+
let p;
|
|
158
|
+
try {
|
|
159
|
+
p = method.apply(event, args);
|
|
160
|
+
if (typeof p === "undefined") p = Promise.resolve();
|
|
161
|
+
if (typeof p.then === "undefined") p = Promise.resolve(p);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
p = Promise.reject(errorObject(e));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// handle response
|
|
167
|
+
p.then(
|
|
168
|
+
(value) => send("async-reply", null, valueToIPCValue(value)),
|
|
169
|
+
(error) => send("async-reply", errorObject(error))
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
var streamTypes = {
|
|
175
|
+
readable: createReadableEvents,
|
|
176
|
+
writable: createWritableEvents,
|
|
177
|
+
duplex: createDuplexEvents,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (streamTypes[type]) {
|
|
181
|
+
return await handleStream(
|
|
182
|
+
event,
|
|
183
|
+
method,
|
|
184
|
+
requestId,
|
|
185
|
+
args,
|
|
186
|
+
streamTypes[type],
|
|
187
|
+
send
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
api.emit(
|
|
192
|
+
"error",
|
|
193
|
+
new Error(`Invalid method type "${type}" for "${methodName}"`),
|
|
194
|
+
arguments
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
async function handleStream(
|
|
199
|
+
event,
|
|
200
|
+
method,
|
|
201
|
+
requestId,
|
|
202
|
+
args,
|
|
203
|
+
createStreamEvents,
|
|
204
|
+
send
|
|
205
|
+
) {
|
|
206
|
+
// call duplex
|
|
207
|
+
let stream;
|
|
208
|
+
let error;
|
|
209
|
+
try {
|
|
210
|
+
stream = method.apply(event, args);
|
|
211
|
+
if (!stream) {
|
|
212
|
+
send("stream-error", "Empty stream response");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
send("stream-error", "" + e);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// handle promises
|
|
221
|
+
if (stream.then) {
|
|
222
|
+
try {
|
|
223
|
+
stream = await stream; // wait for it
|
|
224
|
+
} catch (e) {
|
|
225
|
+
send("stream-error", "" + e);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
trackWebcontentsStreams(event.sender, requestId, stream);
|
|
231
|
+
var events = createStreamEvents(event, stream, requestId, send);
|
|
232
|
+
hookUpEventsAndUnregister(stream, events);
|
|
233
|
+
|
|
234
|
+
// done
|
|
235
|
+
event.returnValue = { success: true };
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function hookUpEventsAndUnregister(stream, events) {
|
|
240
|
+
Object.keys(events).forEach((key) => stream.on(key, events[key]));
|
|
241
|
+
stream.unregisterEvents = () => {
|
|
242
|
+
Object.keys(events).forEach((key) =>
|
|
243
|
+
stream.removeListener(key, events[key])
|
|
244
|
+
);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createReadableEvents(event, stream, requestId, send) {
|
|
249
|
+
return {
|
|
250
|
+
data: (chunk) =>
|
|
251
|
+
send("stream-data", valueToIPCValue(chunk), undefined, true),
|
|
252
|
+
close: () => send("stream-close"),
|
|
253
|
+
error: (err) => {
|
|
254
|
+
stream.unregisterEvents();
|
|
255
|
+
send("stream-error", err ? err.message : "");
|
|
256
|
+
},
|
|
257
|
+
end: () => {
|
|
258
|
+
stream.unregisterEvents(); // TODO does calling this in 'end' mean that 'close' will never be sent?
|
|
259
|
+
send("stream-end");
|
|
260
|
+
webcontentsStreams[event.sender.id][requestId] = null;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function createWritableEvents(event, stream, requestId, send) {
|
|
266
|
+
return {
|
|
267
|
+
drain: () => send("stream-drain", undefined, undefined, true),
|
|
268
|
+
close: () => send("stream-close"),
|
|
269
|
+
error: (err) => {
|
|
270
|
+
stream.unregisterEvents();
|
|
271
|
+
send("stream-error", err ? err.message : "");
|
|
272
|
+
},
|
|
273
|
+
finish: () => {
|
|
274
|
+
stream.unregisterEvents();
|
|
275
|
+
send("stream-finish");
|
|
276
|
+
webcontentsStreams[event.sender.id][requestId] = null;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createDuplexEvents(event, stream, requestId, send) {
|
|
282
|
+
return Object.assign(
|
|
283
|
+
createWritableEvents(event, stream, requestId, send),
|
|
284
|
+
createReadableEvents(event, stream, requestId, send)
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// special methods
|
|
289
|
+
function trackWebcontentsStreams(webcontents, requestId, stream) {
|
|
290
|
+
// track vs. sender's lifecycle
|
|
291
|
+
if (!webcontentsStreams[webcontents.id]) {
|
|
292
|
+
webcontentsStreams[webcontents.id] = {};
|
|
293
|
+
// listen for webcontent close event
|
|
294
|
+
webcontents.once(
|
|
295
|
+
"did-navigate",
|
|
296
|
+
closeAllWebcontentsStreams(webcontents.id)
|
|
297
|
+
);
|
|
298
|
+
webcontents.once("destroyed", closeAllWebcontentsStreams(webcontents.id));
|
|
299
|
+
}
|
|
300
|
+
webcontentsStreams[webcontents.id][requestId] = stream;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function streamRequestWrite(webcontentsId, requestId, args) {
|
|
304
|
+
var stream = webcontentsStreams[webcontentsId][requestId];
|
|
305
|
+
|
|
306
|
+
if (stream && typeof stream.write == "function") {
|
|
307
|
+
stream.write(...args);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function streamRequestEnd(webcontentsId, requestId, args) {
|
|
311
|
+
var stream = webcontentsStreams[webcontentsId][requestId];
|
|
312
|
+
if (stream && typeof stream.end == "function") stream.end(...args);
|
|
313
|
+
}
|
|
314
|
+
function streamRequestClose(webcontentsId, requestId, args) {
|
|
315
|
+
var stream = webcontentsStreams[webcontentsId][requestId];
|
|
316
|
+
if (!stream) return;
|
|
317
|
+
// try .close
|
|
318
|
+
if (typeof stream.close == "function") stream.close(...args);
|
|
319
|
+
// hmm, try .destroy
|
|
320
|
+
else if (typeof stream.destroy == "function") stream.destroy(...args);
|
|
321
|
+
// oye, last shot: end()
|
|
322
|
+
else if (typeof stream.end == "function") stream.end(...args);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// helpers
|
|
326
|
+
function closeAllWebcontentsStreams(webcontentsId) {
|
|
327
|
+
return (e) => {
|
|
328
|
+
if (!webcontentsStreams[webcontentsId]) return;
|
|
329
|
+
|
|
330
|
+
// close all of the open streams
|
|
331
|
+
for (var requestId in webcontentsStreams[webcontentsId]) {
|
|
332
|
+
if (webcontentsStreams[webcontentsId][requestId]) {
|
|
333
|
+
webcontentsStreams[webcontentsId][requestId].unregisterEvents();
|
|
334
|
+
streamRequestClose(webcontentsId, requestId, []);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// stop tracking
|
|
339
|
+
delete webcontentsStreams[webcontentsId];
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return api;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
function errorObject(error) {
|
|
347
|
+
var copy = Object.assign({}, error);
|
|
348
|
+
copy.message = error.message || error.toString();
|
|
349
|
+
if (error.name) copy.name = error.name;
|
|
350
|
+
return copy;
|
|
351
|
+
}
|