'use strict' import { PromiseIOClient, defaultClientConfig } from './PromiseIO/Client' import * as T from './Types'; import * as I from './Interfaces'; import { stripAfterEquals, appendComma } from './Utils'; import { CALLBACK_NAME, DESTROY_PREFIX, SOCKET_NOT_CONNECTED, UNKNOWN_RPC_IDENTIFIER, USER_DEFINED_TIMEOUT } from './Strings'; /** * A websocket-on-steroids with built-in RPC capabilities */ export class RPCSocket implements I.Socket { static async makeSocket(port: number, server: string, sesame?: string, conf: T.ClientConfig = defaultClientConfig): Promise> { const socket = new RPCSocket(port, server, conf) return await socket.connect(sesame) } private socket: I.Socket private handlers: { [name in string]: T.GenericFunction[] } = { error: [], close: [] } private hooks: { [name in string]: T.GenericFunction } = {} /** * * @param port Port to connect to * @param server Server address * @param tls @default false use TLS */ constructor(public port: number, public address: string, private conf: T.ClientConfig = defaultClientConfig) { Object.defineProperty(this, 'socket', { value: undefined, writable: true }) this.hook(UNKNOWN_RPC_IDENTIFIER, (err) => this.handlers['error'].forEach(handler => handler(err))) } /** * Hooks a handler to a function name. Use {@link call} to trigger it. * @param name The function name to listen on * @param handler The handler to attach */ public hook(name: string, handler: (...args: any[]) => any | Promise) { if (!this.socket) { this.hooks[name] = handler } else { this.socket.hook(name, handler) } } /** * Hooks a handler to a function name. Use {@link call} to trigger it. * @param name The function name to listen on * @param handler The handler to attach */ public bind(name: string, handler: (...args: any[]) => any | Promise) { if (!this.socket) { this.hooks[name] = handler } else { this.socket.bind(name, handler) } } /** * Removes a {@link hook} listener by name. * @param name The function name */ public unhook(name: string) { if (!this.socket) { delete this.hooks[name] } else { this.socket.unhook(name) } } /** * Attach a listener to error or close events * @param type 'error' or 'close' * @param f The listener to attach */ public on(type: string, f: T.GenericFunction) { if (!this.socket) { if (!this.handlers[type]) this.handlers[type] = [] this.handlers[type].push(f) } else { this.socket.on(type, f) } } /** * Emit a LOCAL event * @param eventName The event name to emit under * @param data The data the event carries */ public emit(eventName: string, data: any) { if (!this.socket) return this.socket.emit(eventName, data) } /** * Closes the socket. It may attempt to reconnect. */ public close() { if (!this.socket) return; this.socket.close() } /** * Trigger a hooked handler on the server * @param rpcname The function to call * @param args other arguments */ public async call(rpcname: string, ...args: any[]): Promise { if (!this.socket) throw new Error(SOCKET_NOT_CONNECTED) try { if(!this.conf.callTimeoutMs || this.conf.callTimeoutMs <= 0) return await this.socket.call.apply(this.socket, [rpcname, ...args]) else if(this.conf.callTimeoutMs){ return await Promise.race([ this.socket.call.apply(this.socket, [rpcname, ...args]), new Promise((_, rej) => { setTimeout(_ => rej(USER_DEFINED_TIMEOUT(this.conf.callTimeoutMs)), this.conf.callTimeoutMs) }) ]) }else{ return await this.socket.call.apply(this.socket, [rpcname, ...args]) } } catch (e) { this.emit('error', e) throw e } } /** * An alternative to call that does not wait for confirmation and doesn't return a value. * @param rpcname The function to call * @param args other arguments */ public async fire(rpcname: string, ...args: any[]): Promise { if (!this.socket) throw new Error(SOCKET_NOT_CONNECTED) await this.socket.fire.apply(this.socket, [rpcname, ...args]) } /** * Connects to the server and attaches available RPCs to this object */ public async connect(sesame?: string): Promise> { try { this.socket = await PromiseIOClient.connect(this.port, this.address, this.conf) } catch (e) { this.handlers['error'].forEach(h => h(e)) throw e } Object.entries(this.handlers).forEach(([k, v]) => { v.forEach(h => this.socket.on(k, h)) }) Object.entries(this.hooks).forEach((kv: [string, T.GenericFunction]) => { this.socket.hook(kv[0], kv[1]) }) const info: T.ExtendedRpcInfo[] = await this.info(sesame) info.forEach(i => { let f: any switch (i.type) { case 'Call': f = this.callGenerator(i.uniqueName, i.argNames, sesame) break case 'Hook': f = this.frontEndHookGenerator(i.uniqueName, i.argNames, sesame) break } if (this[i.owner] == null) this[i.owner] = {} this[i.owner][i.name] = f this[i.owner][i.name].bind(this) }) return >(this as any) } /** * Get a list of available RPCs from the server */ public async info(sesame?: string) { if (!this.socket) throw new Error(SOCKET_NOT_CONNECTED) return await this.socket.call('info', sesame) } /** * Utility {@link AsyncFunction} generator * @param fnName The function name * @param fnArgs A string-list of parameters */ private callGenerator(fnName: string, fnArgs: string[], sesame?: string): T.GenericFunction { const headerArgs = fnArgs.join(",") const argParams = fnArgs.map(stripAfterEquals).join(",") sesame = appendComma(sesame) return eval(`async (${headerArgs}) => { const returnvalue = await this.call("${fnName}", ${sesame} ${argParams}) return returnvalue }`) } /** * Utility {@link HookFunction} generator * @param fnName The function name * @param fnArgs A string-list of parameters */ private frontEndHookGenerator(fnName: string, fnArgs: string[], sesame?: string): T.GenericFunction { if (sesame) fnArgs.shift() let headerArgs = fnArgs.join(",") const argParams = fnArgs.map(stripAfterEquals).join(",") sesame = appendComma(sesame, true) headerArgs = fnArgs.length > 0 ? headerArgs + "," : headerArgs const destroy_prefix = DESTROY_PREFIX const frontendHookStr = ` async (${CALLBACK_NAME}, ${headerArgs}) => { const r = await this.call("${fnName}", ${sesame} ${argParams}) try{ if(r){ if(r.uuid){ ${CALLBACK_NAME}['destroy'] = () => { this.socket.fire(destroy_prefix+r.uuid) this.socket.unhook(r.uuid) } ${CALLBACK_NAME} = ${CALLBACK_NAME}.bind({ destroy: ${CALLBACK_NAME}['destroy'] }) this.socket.hook(r.uuid, (...args) => { ${CALLBACK_NAME}.apply(${CALLBACK_NAME}, args) }) } return r.return }else{ throw new Error("Empty response") } }catch(e){ throw e } }` return eval(frontendHookStr) } }