import * as uuidv4 from "uuid/v4" import * as T from "./Types"; import * as I from "./Interfaces"; import { Socket } from "socket.io" /** * Translate an RPC to RPCInfo for serialization. * @param rpc The RPC to transform * @param owner The owning RPC group's name * @param errorHandler The error handler to invoke when something goes wrong * @param sesame optional sesame phrase to prepend before all RPC arguments * @throws Error on RPC without name property */ export const rpcToRpcinfo = (socket: I.Socket, rpc: T.RPC, owner: string, errorHandler: T.ErrorHandler, sesame?: T.SesameFunction): T.RpcInfo => { switch (typeof rpc) { case "object": if (rpc['call']) { const _rpc: T.CallRPC = rpc return { owner: owner, argNames: extractArgs(rpc['call']), type: "Call", name: rpc.name, call: sesame ? async ($__sesame__$, ...args) => { if (sesame($__sesame__$)) return await rpc['call'].apply({}, args); socket.close() } : rpc['call'], // check & remove sesame } } else { const _rpc: T.HookRPC = rpc const generator = hookGenerator(_rpc, errorHandler, sesame) return { owner: owner, argNames: extractArgs(generator(undefined)), type: "Hook", name: _rpc.name, generator: generator, } } case "function": if (!rpc.name) throw new Error(` RPC did not provide a name. \nUse 'funtion name(..){ .. }' syntax instead. \n \n<------------OFFENDING RPC: \n${rpc.toString()} \n>------------OFFENDING RPC`) return { owner: owner, argNames: extractArgs(rpc), type: "Call", name: rpc.name, call: sesame ? async ($__sesame__$, ...args) => { if (sesame($__sesame__$)) return await rpc.apply({}, args); throw makeError(rpc.name) } : rpc, // check & remove sesame } } throw new Error("Bad socketIORPC type " + typeof rpc) } /** * Utility function to apply the RPCs of an {@link RPCExporter}. * @param serverSocket The websocket (implementation: socket.io) to hook on * @param exporter The exporter * @param makeUnique @default true Attach a suffix to RPC names */ export function rpcHooker(serverSocket: I.Socket, exporter: I.RPCExporter, errorHandler: T.ErrorHandler, sesame?: T.SesameFunction, makeUnique = true): T.ExtendedRpcInfo[] { const owner = exporter.name const RPCs = typeof exporter.RPCs === "function" ? exporter.RPCs() : exporter.RPCs return RPCs .map(rpc => rpcToRpcinfo(serverSocket, rpc, owner, errorHandler, sesame)) .map(info => { const suffix = makeUnique ? "-" + uuidv4().substr(0, 4) : "" const ret: any = info ret.uniqueName = info.name + suffix let rpcFunction = info.type === 'Hook' ? info.generator(serverSocket) : info.call serverSocket.hook(ret.uniqueName, callGenerator(info.name, serverSocket, rpcFunction, errorHandler)) return ret }) } /** * Decorate an RPC with the error handler * @param rpcFunction the function to decorate */ const callGenerator = (rpcName: string, $__socket__$: I.Socket, rpcFunction: T.AnyFunction, errorHandler: T.ErrorHandler): T.AnyFunction => { const argsArr = extractArgs(rpcFunction) const args = argsArr.join(',') const argsStr = argsArr.map(stripAfterEquals).join(',') const callStr = `async (${args}) => { try{ return await rpcFunction(${argsStr}) }catch(e){ errorHandler($__socket__$)(e, rpcName, [${args}]) } }` return eval(callStr); } /** * Utility function to strip parameters like "a = 3" of their defaults * @param str The parameter to modify */ export function stripAfterEquals(str: string): string { return str.split("=")[0] } /** * Utility function to generate {@link HookFunction} from a RPC for backend * @param rpc The RPC to transform * @returns A {@link HookFunction} */ const hookGenerator = (rpc: T.HookRPC, errorHandler: T.ErrorHandler, sesameFn?: T.SesameFunction, injectSocket?: boolean): T.HookInfo['generator'] => { let argsArr = extractArgs(rpc.hook) argsArr.pop() //remove callback param let callArgs = argsArr.join(',') const args = sesameFn ? (['sesame', ...argsArr].join(',')) : callArgs callArgs = appendComma(callArgs, false) const hookStr = ` ($__socket__$) => async (${args}) => { try{ if(sesameFn && !sesameFn(sesame)) return const uuid = uuidv4() const res = await rpc.hook(${callArgs} (...cbargs) => { ${rpc.onCallback ? `rpc.onCallback.apply({}, cbargs)` : ``} $__socket__$.call.apply($__socket__$, [uuid, ...cbargs]) }) ${rpc.onDestroy ? `$__socket__$.bind(uuid, () => { rpc.onDestroy(res, rpc) })` : ``} return {'uuid': uuid, 'return': res} }catch(e){ //can throw to pass exception to client or swallow to keep it local errorHandler($__socket__$)(e, ${rpc.name}, [${args}]) } }` return eval(hookStr) } const makeError = (callName: string) => new Error(`Call not found: ${callName}. ; Zone: ; Task: Promise.then ; Value: Error: Call not found: ${callName}`) /** * Extract a string list of parameters from a function * @param f The source function */ const extractArgs = (f: Function): string[] => { let fn: string fn = (fn = String(f)).substr(0, fn.indexOf(")")).substr(fn.indexOf("(") + 1) return fn !== "" ? fn.split(',') : [] } export function makeSesameFunction(sesame: T.SesameFunction | string): T.SesameFunction { if (typeof sesame === 'function') { return sesame } return (testSesame: string) => { return testSesame === sesame } } export function appendComma(s?: string, turnToString = true): string { if (turnToString) return s ? `'${s}',` : "" return s ? `${s},` : "" } /** * Typescript incorrectly omits the function.name attribute for MethodDeclaration. * This was supposedly fixed (https://github.com/microsoft/TypeScript/issues/5611) but it still is the case. * This function sets the name value for all object members that are functions. */ export function fixNames(o: Object): void { Object.keys(o).forEach(key => { if (typeof o[key] === 'function' && !o[key].name) { Object.defineProperty(o[key], 'name', { value: key }) } }) } /** * Transforms a socket.io instance into one conforming to I.Socket * @param socket A socket.io socket */ export const makePioSocket = (socket: any): I.Socket => { return { id: socket.id, bind: (name: string, listener: T.PioBindListener) => socket.on(name, (...args: any) => listener.apply(null, args)), hook: (name: string, listener: T.PioHookListener) => { const args = extractArgs(listener) let argNames let restParam = args.find(e => e.includes('...')) if (!restParam) { argNames = [...args, '...$__args__$'].join(',') restParam = '$__args__$' } else { argNames = [...args].join(',') restParam = restParam.replace('...', '') } const decoratedListener = eval(`(() => async (${argNames}) => { const __ack__ = ${restParam}.pop() try{ const response = await listener.apply(null, [${argNames}]) __ack__(response) }catch(e){ __ack__({ ...e, stack: e.stack, message: e.message, name: e.name, }) } })()`) socket.on(name, decoratedListener) }, call: (name: string, ...args: any) => { return new Promise((res, rej) => { const params: any = [name, ...args, (resp) => { if (isError(resp)) { const err = new Error() err.stack = resp.stack err.name = resp.name err.message = resp.message return rej(err) } res(resp) }] socket.emit.apply(socket, params) }) }, fire: (name: string, ...args: any) => new Promise((res, rej) => { const params: any = [name, ...args] socket.emit.apply(socket, params) res() }), unhook: (name: string, listener?: T.AnyFunction) => { if (listener) { socket.removeListener(name, listener) } else { socket.removeAllListeners(name) } }, on: (...args) => socket.on.apply(socket, args), emit: (...args) => socket.emit.apply(socket, args), close: () => { socket.disconnect(true) } } } export const isError = function (e) { return e && e.stack && e.message && typeof e.stack === 'string' && typeof e.message === 'string'; }