Skip to content

Commit 450c4e0

Browse files
committed
feat: typed rpc
1 parent bf9aa91 commit 450c4e0

12 files changed

+616
-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 AppletIpcDefinition = {
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, it becomes a requirement to include a `CallbackId` at the **application layer** for every RPC method:
331+
332+
```diff
333+
export type AppletIpcDefinition = {
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

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
childPort = new Unport();
38+
childPort.implementChannel({
39+
send(message) {
40+
messageChannel.port1.postMessage(message);
41+
},
42+
accept(pipe) {
43+
messageChannel.port1.onmessage = (message: MessageEvent<ChannelMessage>) => pipe(message.data);
44+
},
45+
});
46+
child = new Unrpc(childPort);
47+
48+
parentPort = new Unport();
49+
parentPort.implementChannel({
50+
send(message) {
51+
console.log(message);
52+
messageChannel.port2.postMessage(message);
53+
},
54+
accept(pipe) {
55+
messageChannel.port2.onmessage = (message: MessageEvent<ChannelMessage>) => pipe(message.data);
56+
},
57+
});
58+
59+
parent = new Unrpc(parentPort);
60+
});
61+
62+
it('implemented method - asynchronous implementation', async () => {
63+
parent.implement('getInfo', async ({ id }) => ({ user: id }));
64+
const response = child.call('getInfo', { id: 'name' });
65+
expect(response).resolves.toMatchObject({ user: 'name' });
66+
});
67+
68+
it('implemented method - synchronous implementation', async () => {
69+
parent.implement('getInfo', ({ id }) => ({ user: id }));
70+
const response = child.call('getInfo', { id: 'name' });
71+
expect(response).resolves.toMatchObject({ user: 'name' });
72+
});
73+
74+
it('Error: UnrpcNotImplementationError', async () => {
75+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
76+
new UnrpcNotImplementationError('Method getInfo is not implemented'),
77+
);
78+
});
79+
80+
it('Error: UnrpcExecutionErrorError - asynchronous implementation', async () => {
81+
parent.implement('getInfo', async () => {
82+
// @ts-expect-error mock execution error here.
83+
const result = foo;
84+
return result;
85+
});
86+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
87+
new UnrpcExecutionErrorError('foo is not defined'),
88+
);
89+
});
90+
91+
it('Error: UnrpcExecutionErrorError - synchronous implementation', async () => {
92+
parent.implement('getInfo', () => {
93+
// @ts-expect-error mock execution error here.
94+
const result = foo;
95+
return result;
96+
});
97+
expect(child.call('getInfo', { id: 'name' })).rejects.toMatchObject(
98+
new UnrpcExecutionErrorError('foo is not defined'),
99+
);
100+
});
101+
102+
it('complicated case', async () => {
103+
parent.implement('getInfo', async ({ id }) => ({ user: id }));
104+
child.implement('getChildInfo', async ({ name }) => ({ clientKey: name }));
105+
const [response1, response2] = await Promise.all([
106+
child.call('getInfo', { id: 'child' }),
107+
parent.call('getChildInfo', { name: 'parent' }),
108+
]);
109+
expect(response1).toMatchObject({ user: 'child' });
110+
expect(response2).toMatchObject({ clientKey: 'parent' });
111+
});
112+
});

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+
});

examples/child-process-rpc/port.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-disable camelcase */
2+
import { Unport } from '../../lib';
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+
export type ChildPort = Unport<Definition, 'child'>;
30+
export type ParentPort = Unport<Definition, 'parent'>;

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
"build": "run-s build:cjs build:esm build:example",
2121
"dev:cjs": "npm run build:cjs -- --watch",
2222
"dev:esm": "npm run build:esm -- --watch",
23-
"build:cjs": "tsc -p tsconfig.json --module commonjs --outDir lib",
24-
"build:esm": "tsc -p tsconfig.json --module ES2015 --outDir esm",
23+
"build:cjs": "tsc -p tsconfig.src.json --module commonjs --outDir lib",
24+
"build:esm": "tsc -p tsconfig.src.json --module ES2015 --outDir esm",
2525
"dev:example": "tsc -p tsconfig.examples.json --watch",
2626
"build:example": "tsc -p tsconfig.examples.json",
2727
"prepublishOnly": "npm run build",

0 commit comments

Comments
 (0)