new readme and some small fixes

This commit is contained in:
2020-03-18 03:17:31 +01:00
parent 7d580c4c23
commit 81e2115ad9
8 changed files with 526 additions and 132 deletions
+265 -109
View File
@@ -5,7 +5,7 @@
[![Weekly Downloads](https://img.shields.io/npm/dw/rpclibrary?color=important)](https://www.npmjs.com/package/rpclibrary)
[![License Type](https://img.shields.io/npm/l/rpclibrary?color=blueviolet)](https://gitea.nitowa.xyz/docs/rpclibrary/src/branch/master/LICENSE.md)
rpclibrary is a websocket on steroids!
rpclibrary is a simple to use websocket RPC library.
# How to install
```
@@ -16,147 +16,303 @@ npm i rpclibrary
```typescript
import {RPCServer, RPCSocket} from 'rpclibrary'
const port = 1234
const host = 'locahost'
// TL;DR
const echo = (text) => text
const add = (a, b) => a + b
const echo = (x) => x
new RPCServer(20000, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
echo,
add,
]
}])
const server = new RPCServer(port, [{
name: 'HelloWorldRPCGroup',
exportRPCs: () => [
echo, //named function variable
function echof(x){ return x }, //named function
new RPCSocket(20000, 'localhost').connect().then(async sock => {
try{
const RPCs = sock['MyRPCGroup1']
await RPCs.echo("hello!").then(console.log)
await RPCs.add(1, Math.PI).then(console.log)
}catch(e){
console.log(String(e))
}
})
```
# Async and Callbacks?
rpclibrary offers full support for callbacks and Promises.
Please note that **there may only be one callback per RPC and it has to be the last parameter**
```typescript
const getAsync = async () => await new Promise((res, _) => {
setTimeout(() => {
res({
topic: "Hey!!",
message: "Hello World Async!"
})
}, 250)
})
const getCallback = (callback) => {
setTimeout(() => {
try{
callback({
topic: "Hey!!",
message: "Hello World Callback!"
})
}catch(e){
console.log(String(e))
}
}, 250)
return "Please wait for a callback :)"
}
new RPCServer(20000, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
getAsync,
{
name: 'echoExplicit', //describing object
call: async (x) => x
name: 'getCallback',
hook: getCallback,
}
]
}])
const client = new RPCSocket(port, host)
client.connect().then(async () => {
const r0 = await client['HelloWorldRPCGroup'].echo('Hello')
const r1 = await client['HelloWorldRPCGroup'].echof('World')
const r2 = await client['HelloWorldRPCGroup'].echoExplicit('RPC!')
console.log(r0,r1,r2) //Hello World RPC!
new RPCSocket(20000, 'localhost').connect().then(async sock => {
try{
const RPCs = sock['MyRPCGroup1']
await RPCs.getAsync().then(console.log)
await RPCs.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
```
# Using callbacks
# Hooks and Events
rpclibrary offers a special type of call that can be used with callbacks. The callback **has to be the last argument** and **may be the only passed function**.
In order to function, some metadata has to be included in the return value of hooks. On success, the function is expected to return a `{ result: 'Success', uuid: string }` (Types.SubscriptionResponse) or in case of errors a `{ result: 'Error' }`(Types.ErrorResponse).
The uuid, as the name implies, is used to uniquely identify the callback for a given invocation and also dictates the name given to the client-side RPC. Unless you got a preferred way of generating these (e.g. using some kind of unique information important to your task) we recommend [uuid](https://www.npmjs.com/package/uuid) for this purpose.
You should unhook the client socket once you're done with it as not to cause security or control flow issues.
There are a many things you can hook into to manage your connections
```typescript
import {RPCServer, RPCSocket} from 'rpclibrary'
const port = 1234
const host = 'locahost'
const callbacks:Map<string, Function> = new Map()
new RPCServer(port, [{
name: 'HelloWorldRPCGroup',
exportRPCs: () => [
function triggerCallbacks(...messages){ callbacks.forEach(cb => cb.apply({}, messages)) },
new RPCServer(20001, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
echo,
add,
getAsync,
{
name: 'subscribe',
hook: async (callback) => {
const randStr = 'generate_a_random_string_here'
callbacks.set(randStr, callback);
return { result: 'Success', uuid: randStr}
}
},{
name: 'unsubscribe',
call: async (uuid) => { callbacks.delete(uuid) }
name: 'getCallback',
hook: getCallback,
onClose: (response, rpc) => { /* client disconnected */ },
onCallback: (...callbackArgs) => { /* callback triggered */ }
}
]
}])
const client = new RPCSocket(port, host)
client.connect().then(async () => {
const res = await client['HelloWorldRPCGroup'].subscribe(async (...args:any) => {
console.log.apply(console, args)
/* close the callbacks once you're done */
await client['HelloWorldRPCGroup'].unsubscribe(res.uuid)
client.unhook(res.uuid)
})
await client['HelloWorldRPCGroup'].triggerCallbacks("Hello", "World", "Callbacks")
],
}], {
visibility: '127.0.0.1', //0.0.0.0
closeHandler: (socket) => { /* global close handler */ },
connectionHandler: (socket) => { /* new connection made */ },
errorHandler: (socket, error, rpcname, argArr) => { /* An error occured inside a RPC */ },
})
const sock = new RPCSocket(20001, 'localhost')
sock.on('error', (e) => { /* handle error */ })
sock.on('close', () => { /* handle close event */ })
sock.hook('RPCName', (/* arg0, arg1, ..., argN */) => { /* bind client-side RPCs */ })
//Retrieve the socket from connectionHandler (Server-side) and trigger with
//socket.call('RPCName', arg0, arg1, ..., argN)
sock.connect().then(_ => { /* ... */})
```
If you need to include further response data into your `SubscriptionResponse` you can extend it using the server's first generic parameter `SubResType`
# Restricting access
rpclibrary offers some minimalistic permission management
```typescript
new RPCServer<{extension: string}>(port, [{
name: 'MyRPCGroup',
exportRPCs: () => [{
name: 'subscribe',
hook: async (callback) => {
return {
result: 'Success',
uuid: 'very_random_string',
extension: 'your_data_here' //tsc will demand this field
}
//Restricting access
new RPCServer(20002, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
echo,
add,
getAsync,
{
name: 'getCallback',
hook: getCallback,
}
}]}
])
],
}], {
sesame: "sesame open",
/*
OR check sesame dynamically
and refine permissioning with accessfilter (optional)
*/
//sesame: (sesame) => true
//accessFilter: (sesame, exporter) => { return exporter.name === "MyRPCGroup1" && sesame === "sesame open" },
})
new RPCSocket(20002, 'localhost').connect("sesame open").then(async sock => {
try{
const RPCs = sock['MyRPCGroup1']
await RPCs.echo("hello!").then(console.log)
await RPCs.add(1, Math.PI).then(console.log)
await RPCs.getAsync().then(console.log)
await RPCs.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
```
#Experimental typing support
It is possible to declare pseudo-interfaces for servers and clients by using server's second generic parameter.
This feature is currently still in development and considered **unstable and untested**. Use with caution.
# Typescript support
rpclibrary is a typescript-first project and offers full support for typing your RPCs.
**NOTE** that your function implementations have to be currectly typed to make the compiler agree.
Explicit typing is recommended.
Example:
```typescript
echo = (x) => x
/*becomes*/
echo = (x:string) : string => x
```
```typescript
type MyInterface = {
Group1: {
triggerCallbacks: (...args:any[]) => Promise<void>,
subscribe: (param:string, callback:Function) => Promise<SubscriptionResponse<{a: string}>>,
unsubscribe: (uuid:string) => Promise<void>
},
Group2: {
echo: (x:string) => Promise<string>
MyRPCGroup1: {
echo: (x: string) => string
add: (a: number, b: number) => number
getAsync: () => Promise<{ topic: string, message: string }>
getCallback: (callback:Function) => string
}
};
/*
exportRPCs is now type safe. Try swapping echo for badEcho.
Sadly TSC's stack traces aren't the best, but try to scroll to the bottom of them to find useful info like
Type '(x: boolean) => number' is not assignable to type '(x: string) => string'
*/
const badEcho = (x: boolean) : number => 3
new RPCServer<MyInterface>(20003, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
//badEcho,
echo,
add,
getAsync,
{
name: 'getCallback',
hook: getCallback,
}
],
}])
new RPCSocket<MyInterface>(20003, 'localhost').connect().then(async sock => {
try{
await sock.MyRPCGroup1.echo("hello!").then(console.log)
await sock.MyRPCGroup1.add(1, Math.PI).then(console.log)
await sock.MyRPCGroup1.getAsync().then(console.log)
await sock.MyRPCGroup1.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
```
# A class-based scalable pattern for APIs
because long lists of functions quickly become unwieldy, it is smart to break up the RPCs into chunks or features.
A pattern I found to be useful is as follows:
```typescript
interface IMyImplementation {
echo: (x: string) => string
add: (a: number, b: number) => number
getAsync: () => Promise<{ topic: string, message: string }>
getCallback: (callback:Function) => string
}
type MyInterface = {
MyRPCGroup1: {
echo: IMyImplementation['echo']
add: IMyImplementation['add']
getAsync: IMyImplementation['getAsync']
getCallback: IMyImplementation['getCallback']
}
}
```
Create a client using
```typescript
RPCSocket.makeSocket<MyInterface>(port, host).then((async (client) => {
const r = await client.Group2.echo("hee") //tsc knows about available RPCs
}))
/* OR */
class MyImplementation implements IMyImplementation, RPCExporter<MyInterface>{
//"X" as "X" syntax is required to satisfy the type system (as it assumes string to be the true type)
name = "MyRpcGroup11" as "MyRPCGroup1"
//List the functions you declared in MyInterface
exportRPCs = () => [
this.echo,
this.add,
this.getAsync,
this.getCallback
]
//Write your implementations as you normally would
echo = (text: string) => text
add = (a: number, b: number) : number => a + b
getAsync = async () : Promise<{topic: string, message:string}>=> await new Promise((res, _) => {
setTimeout(() => {
res({
topic: "Hey!!",
message: "Hello World Async!"
})
}, 250)
})
getCallback = (callback: Function) : string => {
setTimeout(() => {
try{
callback({
topic: "Hey!!",
message: "Hello World Callback!"
})
}catch(e){
console.log(String(e))
}
}, 250)
return "Please wait for a callback :)"
}
}
type ProjectInterface = MyInterface
//& MyOtherInterface
//& MyOtherOtherInterface
// ...
;
new RPCServer<ProjectInterface>(20004, [new MyImplementation() /*, new MyOtherImplementation(), new MyOtherOtherImplementation() */])
new RPCSocket<ProjectInterface>(20004, 'localhost').connect().then(async sock => {
// ...
})
const client = new RPCSocket(port, host)
client.connect<MyInterface>().then((async (client) => {
const r = await client.Group2.echo("hee") //tsc knows about available RPCs
}))
```
Create a server using
```typescript
new RPCServer<{a:string}, MyInterface>(port,
[{
//...
},{
name: 'Group2', //Auto completion for names
exportRPCs: () => [{
name: 'echo', //this name too!
call: async (x) => x+"llo World!" //the paramter and return types are known by tsc
}]
}]
)
```
# [Full documentation](https://gitea.nitowa.xyz/docs/rpclibrary)
+239
View File
@@ -0,0 +1,239 @@
import { RPCServer, RPCSocket } from './Index'
import { RPCExporter } from './src/Interfaces'
// TL;DR
const echo = (text: string) => text
const add = (a: number, b: number) : number => a + b
const getAsync = async () : Promise<{topic: string, message:string}>=> await new Promise((res, _) => {
setTimeout(() => {
res({
topic: "Hey!!",
message: "Hello World Async!"
})
}, 250)
})
const getCallback = (callback: Function) : string => {
setTimeout(() => {
try{
callback({
topic: "Hey!!",
message: "Hello World Callback!"
})
}catch(e){
console.log(String(e))
}
}, 250)
return "Please wait for a callback :)"
}
new RPCServer(20000, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
echo,
add,
getAsync,
{
name: 'getCallback',
hook: getCallback,
onClose: (response, rpc) => { /* ... */ },
onCallback: (...callbackArgs) => { /* ... */ }
}
]
}])
new RPCSocket(20000, 'localhost').connect().then(async sock => {
try{
const RPCs = sock['MyRPCGroup1']
await RPCs.echo("hello!").then(console.log)
await RPCs.add(1, Math.PI).then(console.log)
await RPCs.getAsync().then(console.log)
await RPCs.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
//Hooks and events
new RPCServer(20001, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
echo,
add,
getAsync,
{
name: 'getCallback',
hook: getCallback,
onClose: (response, rpc) => { /* client disconnected */ },
onCallback: (...callbackArgs) => { /* callback triggered */ }
}
],
}], {
visibility: '127.0.0.1', //0.0.0.0
closeHandler: (socket) => { /* global close handler */ },
connectionHandler: (socket) => { /* new connection made */ },
errorHandler: (socket, error, rpcname, argArr) => { /* An error occured inside a RPC */ },
})
const sock = new RPCSocket(20001, 'localhost')
sock.on('error', (e) => { /* handle error */ })
sock.on('close', () => { /* handle close event */ })
sock.hook('RPCName', (/* arg0, arg1, ..., argN */) => { /* bind client-side RPCs */ })
//Retrieve the socket from connectionHandler (Server-side) and trigger with
//socket.call('RPCName', arg0, arg1, ..., argN)
sock.connect().then(_ => { /* ... */})
//Restricting access
new RPCServer(20002, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
echo,
add,
getAsync,
{
name: 'getCallback',
hook: getCallback,
}
],
}], {
sesame: "sesame open",
/*
OR check sesame dynamically
and refine permissioning with accessfilter (optional)
*/
//sesame: (sesame) => true
//accessFilter: (sesame, exporter) => { return exporter.name === "MyRPCGroup1" && sesame === "sesame open" },
})
new RPCSocket(20002, 'localhost').connect("sesame open").then(async sock => {
try{
const RPCs = sock['MyRPCGroup1']
await RPCs.echo("hello!").then(console.log)
await RPCs.add(1, Math.PI).then(console.log)
await RPCs.getAsync().then(console.log)
await RPCs.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
//TypeScript and pseudo-interfaces
type MyInterface = {
MyRPCGroup1: {
echo: (x: string) => string
add: (a: number, b: number) => number
getAsync: () => Promise<{ topic: string, message: string }>
getCallback: (callback:Function) => string
}
};
/*
exportRPCs is now type safe. Try swapping echo for badEcho.
Sadly TSC's stack traces aren't the best, but try to scroll to the bottom of them to find useful info like
Type '(x: boolean) => number' is not assignable to type '(x: string) => string'
*/
const badEcho = (x: boolean) : number => 3
new RPCServer<MyInterface>(20003, [{
name: 'MyRPCGroup1',
exportRPCs: () => [
//badEcho,
echo,
add,
getAsync,
{
name: 'getCallback',
hook: getCallback,
}
],
}])
new RPCSocket<MyInterface>(20003, 'localhost').connect().then(async sock => {
try{
await sock.MyRPCGroup1.echo("hello!").then(console.log)
await sock.MyRPCGroup1.add(1, Math.PI).then(console.log)
await sock.MyRPCGroup1.getAsync().then(console.log)
await sock.MyRPCGroup1.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
//Class-based pattern
interface IMyImplementation {
echo: (x: string) => string
add: (a: number, b: number) => number
getAsync: () => Promise<{ topic: string, message: string }>
getCallback: (callback:Function) => string
}
type MyIfc = {
MyRPCGroup1: {
echo: IMyImplementation['echo']
add: IMyImplementation['add']
getAsync: IMyImplementation['getAsync']
getCallback: IMyImplementation['getCallback']
}
}
class MyImplementation implements IMyImplementation, RPCExporter<MyIfc>{
//"X" as "X" syntax is required to satisfy the type system (as it assumed string)
name = "MyRpcGroup11" as "MyRPCGroup1"
//List the functions you declared in MyIfc
exportRPCs = () => [
this.echo,
this.add,
this.getAsync,
this.getCallback
]
//Write your implementations as you normally would
echo = (text: string) => text
add = (a: number, b: number) : number => a + b
getAsync = async () : Promise<{topic: string, message:string}>=> await new Promise((res, _) => {
setTimeout(() => {
res({
topic: "Hey!!",
message: "Hello World Async!"
})
}, 250)
})
getCallback = (callback: Function) : string => {
setTimeout(() => {
try{
callback({
topic: "Hey!!",
message: "Hello World Callback!"
})
}catch(e){
console.log(String(e))
}
}, 250)
return "Please wait for a callback :)"
}
}
new RPCServer<MyIfc>(20004, [new MyImplementation(), /* ... other RPCExporters */])
new RPCSocket<MyIfc>(20004, 'localhost').connect().then(async sock => {
try{
await sock.MyRPCGroup1.echo("hello!").then(console.log)
await sock.MyRPCGroup1.add(1, Math.PI).then(console.log)
await sock.MyRPCGroup1.getAsync().then(console.log)
await sock.MyRPCGroup1.getCallback(console.log).then(console.log)
}catch(e){
console.log(String(e))
}
})
-4
View File
@@ -6,10 +6,6 @@ import * as T from './Types';
import * as U from './Utils';
import * as I from './Interfaces';
/**
* A Websocket-server-on-steroids with built-in RPC capabilities
*/
export class RPCServer<
InterfaceT extends T.RPCInterface = T.RPCInterface,
> implements I.Destroyable {
+4 -4
View File
@@ -13,8 +13,8 @@ import { stripAfterEquals, appendComma } from './Utils';
export class RPCSocket<Ifc extends T.RPCInterface = T.RPCInterface> implements I.Socket{
static async makeSocket<T extends T.RPCInterface = T.RPCInterface>(port:number, server: string, sesame?:string, conf?:T.SocketConf): Promise<T.ConnectedSocket<T>> {
const socket = new RPCSocket(port, server, conf)
return await socket.connect<T>(sesame)
const socket = new RPCSocket<T>(port, server, conf)
return await socket.connect(sesame)
}
private socket: I.Socket
@@ -128,7 +128,7 @@ export class RPCSocket<Ifc extends T.RPCInterface = T.RPCInterface> implements I
/**
* Connects to the server and attaches available RPCs to this object
*/
public async connect<T extends T.RPCInterface= Ifc>( sesame?: string ) : Promise<T.ConnectedSocket<T>> {
public async connect( sesame?: string ) : Promise<T.ConnectedSocket<Ifc>> {
this.socket = await bsock.connect(this.port, this.server, this.conf.tls?this.conf.tls:false)
this.errorHandlers.forEach(h => this.socket.on('error', h))
this.closeHandlers.forEach(h => this.socket.on('close', h))
@@ -153,7 +153,7 @@ export class RPCSocket<Ifc extends T.RPCInterface = T.RPCInterface> implements I
this[i.owner][i.name] = f
this[i.owner][i.name].bind(this)
})
return <RPCSocket & T.RPCInterface<T>> (this as any)
return <T.ConnectedSocket<Ifc>> (this as any)
}
/**
+1 -1
View File
@@ -6,7 +6,7 @@ import * as I from "./Interfaces"
*/
export type RPCExporter<
Ifc extends T.RPCInterface = T.RPCInterface,
Name extends keyof Ifc = string,
Name extends keyof Ifc = keyof Ifc,
> = {
name: Name
exportRPCs() : T.RPCDefinitions<Ifc>[Name]
+7 -4
View File
@@ -3,13 +3,12 @@ import { RPCSocket } from "./Frontend";
export type AnyFunction = (...args:any) => any
export type HookFunction = AnyFunction
export type AccessFilter<InterfaceT extends RPCInterface = RPCInterface> = (sesame:string|undefined, exporter: I.RPCExporter<InterfaceT, keyof InterfaceT>) => Promise<boolean>
export type AccessFilter<InterfaceT extends RPCInterface = RPCInterface> = (sesame:string|undefined, exporter: I.RPCExporter<InterfaceT, keyof InterfaceT>) => Promise<boolean> | boolean
export type Visibility = "127.0.0.1" | "0.0.0.0"
export type ConnectionHandler = (socket:I.Socket) => void
export type ErrorHandler = (socket:I.Socket, error:any, rpcName: string, args: any[]) => void
export type CloseHandler = (socket:I.Socket) => void
export type SesameFunction = (sesame : string) => boolean
export type ExceptionHandling = 'local' | 'remote'
export type SesameConf = {
sesame?: string | SesameFunction
}
@@ -20,7 +19,7 @@ export type FrontEndHandlerType = {
export type ExporterArray<InterfaceT extends RPCInterface = RPCInterface> = I.RPCExporter<RPCInterface<InterfaceT>, keyof InterfaceT>[]
export type ConnectedSocket<T extends RPCInterface = RPCInterface> = RPCSocket & T
export type ConnectedSocket<T extends RPCInterface = RPCInterface> = RPCSocket & AsyncIfc<T>
export type ServerConf<InterfaceT extends RPCInterface> = {
accessFilter?: AccessFilter<InterfaceT>
@@ -28,7 +27,6 @@ export type ServerConf<InterfaceT extends RPCInterface> = {
errorHandler?: ErrorHandler
closeHandler?: CloseHandler
visibility?: Visibility
exceptionHandling?: ExceptionHandling
} & SesameConf
export type SocketConf = {
@@ -95,3 +93,8 @@ export type ExtendedRpcInfo = RpcInfo & { uniqueName: string }
export type OnFunction = <T extends "error" | "close">(type: T, f: FrontEndHandlerType[T]) => void
export type HookCloseFunction<T> = (res: T, rpc:HookRPC<any, any>) => any
export type AsyncIfc<Ifc extends RPCInterface> = { [grp in keyof Ifc]: {[rpcname in keyof Ifc[grp]] : AsyncAnyFunction<Ifc[grp][rpcname]> } }
export type AsyncAnyFunction<F extends AnyFunction = AnyFunction> = F extends (...args: Parameters<F>) => infer R ? ((...args: Parameters<F>) => R extends Promise<any> ? R : Promise<R> ) : Promise<any>
+5 -5
View File
@@ -330,7 +330,7 @@ describe('Sesame should unlock the socket', () => {
it('should not work without sesame', (done) => {
const sock = new RPCSocket(21004, "localhost")
sock.connect<SesameTestIfc>( /* no sesame */).then(async (cli) => {
sock.connect( /* no sesame */).then(async (cli) => {
if (!cli.test)
done()
else {
@@ -343,7 +343,7 @@ describe('Sesame should unlock the socket', () => {
it('should fail with wrong sesame', (done) => {
const sock = new RPCSocket(21004, "localhost")
sock.connect<SesameTestIfc>('abasd').then(async (cli) => {
sock.connect('abasd').then(async (cli) => {
if (!cli.test)
done()
else {
@@ -586,8 +586,8 @@ describe("Class binding", () => {
})
beforeEach((done) => {
const s = new RPCSocket(21004, 'localhost')
s.connect<myExporterIfc>("xxx").then(conn => {
const s = new RPCSocket<myExporterIfc>(21004, 'localhost')
s.connect("xxx").then(conn => {
sock = conn
done()
})
@@ -645,7 +645,7 @@ describe("Class binding", () => {
describe("attaching handlers before connecting", () => {
it("fires error if server is unreachable", (done) => {
const sock = new RPCSocket(21004, 'localhost')
const sock = new RPCSocket<myExporterIfc>(21004, 'localhost')
let errorHandleCount = 0
sock.on('error', (err) => {
+1 -1
View File
@@ -9,6 +9,6 @@
"strict": true,
"experimentalDecorators": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "Index.ts"],
"include": ["src/**/*.ts", "test/**/*.ts", "Index.ts", "demo.ts"],
"exclude": ["node_modules"]
}