Skip to content

Commit baa5645

Browse files
authored
feat: typed rpc (#3)
1 parent bf9aa91 commit baa5645

12 files changed

+657
-60
lines changed

.eslintrc.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
module.exports = {
2-
extends: "eslint-config-typescript-library",
2+
extends: 'eslint-config-typescript-library',
3+
rules: {
4+
camelcase: 'off',
5+
},
36
};

.vscode/settings.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"markdown"
99
],
1010
"editor.codeActionsOnSave": {
11-
"source.fixAll.eslint": true
11+
"source.fixAll.eslint": "explicit"
1212
},
1313
"search.exclude": {
1414
"**/.git": true,
@@ -43,7 +43,9 @@
4343
"liveServer.settings.port": 5501,
4444
"js/ts.implicitProjectConfig.strictNullChecks": false,
4545
"cSpell.words": [
46+
"camelcase",
4647
"insx",
47-
"Unport"
48+
"Unport",
49+
"Unrpc"
4850
]
4951
}

README.md

+128
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Each of these JSContexts exhibits distinct methods of communicating with the ext
3636
- [Channel](#channel-1)
3737
- [.pipe()](#pipe)
3838
- [ChannelMessage](#channelmessage)
39+
- [Unrpc (Experimental)](#unrpc-experimental)
3940
- [🤝 Contributing](#-contributing)
4041
- [🤝 Credits](#-credits)
4142
- [LICENSE](#license)
@@ -299,6 +300,133 @@ The `ChannelMessage` type is used for the message in the `onMessage` method.
299300
import { ChannelMessage } from 'unport';
300301
```
301302

303+
### Unrpc (Experimental)
304+
305+
Starting with the 0.6.0 release, we are experimentally introducing support for Typed [RPC (Remote Procedure Call)](https://en.wikipedia.org/wiki/Remote_procedure_call).
306+
307+
When dealing with a single Port that requires RPC definition, we encounter a problem related to the programming paradigm. It's necessary to define `Request` and `Response` messages such as:
308+
309+
```ts
310+
export type IpcDefinition = {
311+
a2b: {
312+
callFoo: {
313+
input: string;
314+
};
315+
};
316+
b2a: {
317+
callFooCallback: {
318+
result: string;
319+
};
320+
};
321+
};
322+
```
323+
324+
In the case where an RPC call needs to be encapsulated, the API might look like this:
325+
326+
```ts
327+
function rpcCall(request: { input: string; }): Promise<{ result: string; }>;
328+
```
329+
330+
Consequently, to associate a callback function, it becomes a requirement to include a `CallbackId` at the **application layer** for every RPC method:
331+
332+
```diff
333+
export type IpcDefinition = {
334+
a2b: {
335+
callFoo: {
336+
input: string;
337+
+ callbackId: string;
338+
};
339+
};
340+
b2a: {
341+
callFooCallback: {
342+
result: string;
343+
+ callbackId: string;
344+
};
345+
};
346+
};
347+
```
348+
349+
`Unrpc` is provided to address this issue, enabling support for Typed RPC starting from the **protocol layer**:
350+
351+
```ts
352+
import { Unrpc } from 'unport';
353+
354+
// "parentPort" is a Port defined based on Unport in the previous example.
355+
const parent = new Unrpc(parentPort);
356+
357+
// Implementing an RPC method.
358+
parent.implement('callFoo', request => ({
359+
user: `parent (${request.id})`,
360+
}));
361+
362+
// Emit a SYN event.
363+
parent.port.postMessage('syn', { pid: 'parent' });
364+
365+
// Listen for the ACK message.
366+
parent.port.onMessage('ack', async payload => {
367+
// Call an RPC method as defined by the "child" port.
368+
const response = await parent.call('getChildInfo', {
369+
name: 'parent',
370+
});
371+
});
372+
```
373+
374+
The implementation on the `child` side is as follows:
375+
376+
```ts
377+
import { Unrpc } from 'unport';
378+
379+
// "parentPort" is a Port also defined based on Unport.
380+
const child = new Unrpc(childPort);
381+
382+
child.implement('getChildInfo', request => ({
383+
clientKey: `[child] ${request.name}`,
384+
}));
385+
386+
// Listen for the SYN message.
387+
child.port.onMessage('syn', async payload => {
388+
const response = await child.call('getInfo', { id: '<child>' });
389+
// Acknowledge the SYN event.
390+
child.port.postMessage('ack', { pid: 'child' });
391+
});
392+
```
393+
394+
The types are defined as such:
395+
396+
```ts
397+
import { Unport } from 'unport';
398+
399+
export type Definition = {
400+
parent2child: {
401+
syn: {
402+
pid: string;
403+
};
404+
getInfo__callback: {
405+
user: string;
406+
};
407+
getChildInfo: {
408+
name: string;
409+
}
410+
};
411+
child2parent: {
412+
getInfo: {
413+
id: string;
414+
};
415+
getChildInfo__callback: {
416+
clientKey: string;
417+
};
418+
ack: {
419+
pid: string;
420+
};
421+
};
422+
};
423+
424+
export type ChildPort = Unport<Definition, 'child'>;
425+
export type ParentPort = Unport<Definition, 'parent'>;
426+
```
427+
428+
In comparison to Unport, the only new concept to grasp is that the RPC response message key must end with `__callback`. Other than that, no additional changes are necessary! `Unrpc` also offers comprehensive type inference based on this convention; for instance, you won't be able to implement an RPC method that is meant to serve as a response.
429+
302430
## 🤝 Contributing
303431

304432
Contributions, issues and feature requests are welcome!

__tests__/rpc.test.ts

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { ChannelMessage, Unport, Unrpc, UnrpcExecutionErrorError, UnrpcNotImplementationError } from '../src';
3+
4+
export type Definition = {
5+
parent2child: {
6+
syn: {
7+
pid: string;
8+
};
9+
getInfo__callback: {
10+
user: string;
11+
};
12+
getChildInfo: {
13+
name: string;
14+
}
15+
};
16+
child2parent: {
17+
getInfo: {
18+
id: string;
19+
};
20+
getChildInfo__callback: {
21+
clientKey: string;
22+
};
23+
ack: {
24+
pid: string;
25+
};
26+
};
27+
};
28+
29+
describe('Unrpc', () => {
30+
let childPort: Unport<Definition, 'child'>;
31+
let parentPort: Unport<Definition, 'parent'>;
32+
let child: Unrpc<Definition, 'child'>;
33+
let parent: Unrpc<Definition, 'parent'>;
34+
35+
beforeEach(() => {
36+
const messageChannel = new MessageChannel();
37+
if (childPort) childPort.destroy();
38+
childPort = new Unport();
39+
childPort.implementChannel({
40+
send(message) {
41+
messageChannel.port1.postMessage(message);
42+
},
43+
accept(pipe) {
44+
messageChannel.port1.onmessage = (message: MessageEvent<ChannelMessage>) => pipe(message.data);
45+
},
46+
destroy() {
47+
messageChannel.port1.close();
48+
},
49+
});
50+
child = new Unrpc(childPort);
51+
52+
parentPort = new Unport();
53+
parentPort.implementChannel({
54+
send(message) {
55+
console.log(message);
56+
messageChannel.port2.postMessage(message);
57+
},
58+
accept(pipe) {
59+
messageChannel.port2.onmessage = (message: MessageEvent<ChannelMessage>) => pipe(message.data);
60+
},
61+
destroy() {
62+
messageChannel.port2.close();
63+
},
64+
});
65+
66+
parent = new Unrpc(parentPort);
67+
});
68+
69+
it('implemented method - asynchronous implementation', async () => {
70+
parent.implement('getInfo', async ({ id }) => ({ user: id }));
71+
const response = child.call('getInfo', { id: 'name' });
72+
expect(response).resolves.toMatchObject({ user: 'name' });
73+
});
74+
75+
it('implemented method - synchronous implementation', async () => {
76+
parent.implement('getInfo', ({ id }) => ({ user: id }));
77+
const response = child.call('getInfo', { id: 'name' });
78+
expect(response).resolves.toMatchObject({ user: 'name' });
79+
});
80+
81+
it('Error: UnrpcNotImplementationError', async () => {
82+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
83+
new UnrpcNotImplementationError('Method getInfo is not implemented'),
84+
);
85+
});
86+
87+
it('Error: UnrpcExecutionErrorError - script error - asynchronous implementation', async () => {
88+
parent.implement('getInfo', async () => {
89+
// @ts-expect-error mock execution error here.
90+
const result = foo;
91+
return result;
92+
});
93+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
94+
new UnrpcExecutionErrorError('foo is not defined'),
95+
);
96+
});
97+
98+
it('Error: UnrpcExecutionErrorError - script error - synchronous implementation', async () => {
99+
parent.implement('getInfo', () => {
100+
// @ts-expect-error mock execution error here.
101+
const result = foo;
102+
return result;
103+
});
104+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
105+
new UnrpcExecutionErrorError('foo is not defined'),
106+
);
107+
});
108+
109+
it('Error: UnrpcExecutionErrorError - user throws error', async () => {
110+
parent.implement('getInfo', () => {
111+
throw new Error('mock error');
112+
});
113+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
114+
new UnrpcExecutionErrorError('mock error'),
115+
);
116+
});
117+
118+
it('complicated case', async () => {
119+
parent.implement('getInfo', async ({ id }) => ({ user: id }));
120+
child.implement('getChildInfo', async ({ name }) => ({ clientKey: name }));
121+
122+
let finishHandshake: (value?: unknown) => void;
123+
const handshakePromise = new Promise(resolve => {
124+
finishHandshake = resolve;
125+
});
126+
127+
/**
128+
* Simulates a handshake
129+
*/
130+
parent.port.postMessage('syn', { pid: 'parent' });
131+
parent.port.onMessage('ack', async payload => {
132+
expect(payload.pid).toBe('child');
133+
finishHandshake();
134+
});
135+
child.port.onMessage('syn', async payload => {
136+
expect(payload.pid).toBe('parent');
137+
child.port.postMessage('ack', { pid: 'child' });
138+
});
139+
140+
/**
141+
* Wait handshake finished
142+
*/
143+
await handshakePromise;
144+
145+
const [response1, response2] = await Promise.all([
146+
child.call('getInfo', { id: 'child' }),
147+
parent.call('getChildInfo', { name: 'parent' }),
148+
]);
149+
expect(response1).toMatchObject({ user: 'child' });
150+
expect(response2).toMatchObject({ clientKey: 'parent' });
151+
});
152+
});

examples/child-process-rpc/child.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Unport, Unrpc, ChannelMessage } from '../../lib';
2+
import { ChildPort } from './port';
3+
4+
// 1. Initialize a port
5+
const childPort: ChildPort = new Unport();
6+
7+
// 2. Implement a Channel based on underlying IPC capabilities
8+
childPort.implementChannel({
9+
send(message) {
10+
process.send && process.send(message);
11+
},
12+
accept(pipe) {
13+
process.on('message', (message: ChannelMessage) => {
14+
pipe(message);
15+
});
16+
},
17+
});
18+
19+
// 3. Initialize a rpc client
20+
const childRpcClient = new Unrpc(childPort);
21+
childRpcClient.implement('getChildInfo', request => ({
22+
clientKey: `[child] ${request.name}`,
23+
}));
24+
childRpcClient.port.onMessage('syn', async payload => {
25+
console.log('[child] [event] [syn] [result]', payload);
26+
const response = await childRpcClient.call('getInfo', { id: '<child>' });
27+
console.log('[child] [rpc] [getInfo] [response]', response);
28+
childPort.postMessage('ack', { pid: 'child' });
29+
});

examples/child-process-rpc/parent.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { join } from 'path';
2+
import { fork } from 'child_process';
3+
import { Unport, Unrpc, ChannelMessage } from '../../lib';
4+
import { ParentPort } from './port';
5+
6+
// 1. Initialize a port
7+
const parentPort: ParentPort = new Unport();
8+
9+
// 2. Implement a Channel based on underlying IPC capabilities
10+
const childProcess = fork(join(__dirname, './child.js'));
11+
parentPort.implementChannel({
12+
send(message) {
13+
childProcess.send(message);
14+
},
15+
accept(pipe) {
16+
childProcess.on('message', (message: ChannelMessage) => {
17+
pipe(message);
18+
});
19+
},
20+
destroy() {
21+
childProcess.removeAllListeners('message');
22+
childProcess.kill();
23+
},
24+
});
25+
26+
// 3. Initialize a rpc client from port.
27+
const parentRpcClient = new Unrpc(parentPort);
28+
29+
parentRpcClient.implement('getInfo', request => ({
30+
user: `parent (${request.id})`,
31+
}));
32+
parentRpcClient.port.postMessage('syn', { pid: 'parent' });
33+
parentRpcClient.port.onMessage('ack', async payload => {
34+
console.log('[parent] [event] [ack] [result]', payload);
35+
const response = await parentRpcClient.call('getChildInfo', {
36+
name: 'parent',
37+
});
38+
console.log('[parent] [rpc] [getChildInfo] [response]', response);
39+
setTimeout(() => {
40+
console.log('destroy');
41+
parentPort.destroy();
42+
}, 1000);
43+
});

0 commit comments

Comments
 (0)