From a1f4055194163c750d7b685422945574788d18f0 Mon Sep 17 00:00:00 2001 From: nitowa Date: Tue, 5 Apr 2022 05:41:08 +0200 Subject: [PATCH] probably a working typecheck for callbacks --- Index.ts | 3 +- src/Backend.ts | 12 ++----- src/Decorator.ts | 74 ++++++++++++++++++++++++++++++++++++++++ src/Frontend.ts | 33 +++++++++++------- src/Strings.ts | 4 ++- src/Types.ts | 85 ++++++++++++++++++++++++++++++++-------------- src/Utils.ts | 13 ++++--- test/Test.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++--- tsconfig.json | 2 +- 9 files changed, 254 insertions(+), 60 deletions(-) create mode 100644 src/Decorator.ts diff --git a/Index.ts b/Index.ts index 4eded91..2220961 100644 --- a/Index.ts +++ b/Index.ts @@ -2,4 +2,5 @@ export * from './src/Backend'; export * from './src/Frontend'; export * from './src/Interfaces' export * from './src/Types'; -export * from './src/Utils' \ No newline at end of file +export * from './src/Utils' +export * from './src/Decorator' \ No newline at end of file diff --git a/src/Backend.ts b/src/Backend.ts index 4aa4b1e..bcf83df 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -5,7 +5,7 @@ import { PromiseIO } from "./PromiseIO/Server"; import * as T from './Types'; import * as U from './Utils'; import * as I from './Interfaces'; -import { BAD_CONFIG_PARAM, UNKNOWN_RPC_IDENTIFIER, UNKNOWN_RPC_SERVER } from './Strings'; +import { BAD_CONFIG_PARAM, DESTROY_PREFIX, RPC_NO_NAME, UNKNOWN_RPC_IDENTIFIER, UNKNOWN_RPC_SERVER } from './Strings'; export class RPCServer< InterfaceT extends T.RPCInterface = T.RPCInterface, @@ -65,13 +65,7 @@ export class RPCServer< let badRPC = exporters.flatMap(ex => typeof ex.RPCs === "function" ? ex.RPCs() : (ex as any)).find(rpc => !rpc.name) if (badRPC) { - throw new Error(` - RPC did not provide a name. - \nUse 'funtion name(..){ .. }' syntax instead. - \n - \n<------------OFFENDING RPC: - \n`+ badRPC.toString() + ` - \n>------------OFFENDING RPC`) + throw new Error(RPC_NO_NAME(badRPC.toString())) } try { @@ -114,7 +108,7 @@ export class RPCServer< if (this.conf.throwOnUnknownRPC) { clientSocket.on("*", (packet) => { if (!infos.some(i => i.uniqueName === packet.data[0])) { - if (packet.data[0].startsWith('destroy_')) return + if (packet.data[0].startsWith(DESTROY_PREFIX)) return this.errorHandler(clientSocket, new Error(UNKNOWN_RPC_SERVER(packet.data[0])), packet.data[0], [...packet.data].splice(1), true) } }) diff --git a/src/Decorator.ts b/src/Decorator.ts new file mode 100644 index 0000000..575824e --- /dev/null +++ b/src/Decorator.ts @@ -0,0 +1,74 @@ +import { CLASSNAME_ATTRIBUTE } from "./Strings" + +export abstract class DeserializerFactory { + static entityClasses = {} + + static from(object: any) { + if(!object){ + return + } + + if(typeof object !== 'object'){ //definitely not a class object + return object + } + + if(Array.isArray(object)){ + return object.map(DeserializerFactory.from) + } + + const clazz = DeserializerFactory.entityClasses[object[CLASSNAME_ATTRIBUTE]] + delete object[CLASSNAME_ATTRIBUTE] + + if(!clazz){ //anonymous object or class not registered as @Serializable + Object.keys(object).forEach(key => { + object[key] = DeserializerFactory.from(object[key]) + }) + return object + } + + const obj = new clazz() + Object.keys(object).forEach(key => { + obj[key] = DeserializerFactory.from(object[key]) + }) + return obj + } + + + + static makeDeserializable(object: any){ + if(!object) + return + + if(typeof object !== 'object') + return object + + if(object.constructor.name === 'Object'){ + Object.keys(object).forEach(key => { + object[key] = DeserializerFactory.makeDeserializable(object[key]) + }) + return object + } + + object[CLASSNAME_ATTRIBUTE] = object.constructor.name + Object.keys(object).forEach(key => { + object[key] = DeserializerFactory.makeDeserializable(object[key]) + }) + + return object + } +} + +export function Serializable(attr?: any) { + return function _Serializable(clazz: T) { + DeserializerFactory.entityClasses[clazz.name] = clazz + + return clazz + /* + return class extends clazz{ + constructor(...args: any[]) { + super(...args) + } + } + */ + } +} \ No newline at end of file diff --git a/src/Frontend.ts b/src/Frontend.ts index 95eda1c..15cba90 100644 --- a/src/Frontend.ts +++ b/src/Frontend.ts @@ -4,7 +4,9 @@ import { PromiseIOClient, defaultClientConfig } from './PromiseIO/Client' import * as T from './Types'; import * as I from './Interfaces'; import { stripAfterEquals, appendComma } from './Utils'; -import { SOCKET_NOT_CONNECTED, UNKNOWN_RPC_IDENTIFIER, USER_DEFINED_TIMEOUT } from './Strings'; +import { DESTROY_PREFIX, SOCKET_NOT_CONNECTED, UNKNOWN_RPC_IDENTIFIER, USER_DEFINED_TIMEOUT } from './Strings'; +import { DeserializerFactory } from './Decorator'; +DeserializerFactory /** @@ -117,17 +119,21 @@ export class RPCSocket implements I 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 - 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) - }) - ]) + 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 @@ -203,9 +209,10 @@ export class RPCSocket implements I const headerArgs = fnArgs.join(",") const argParams = fnArgs.map(stripAfterEquals).join(",") sesame = appendComma(sesame) - + const deserializer = DeserializerFactory return eval(`async (${headerArgs}) => { - return await this.call("${fnName}", ${sesame} ${argParams}) + const returnvalue = await this.call("${fnName}", ${sesame} ${argParams}) + return deserializer.from(returnvalue) }`) } @@ -223,6 +230,8 @@ export class RPCSocket implements I const argParams = fnArgs.map(stripAfterEquals).join(",") sesame = appendComma(sesame, true) headerArgs = fnArgs.length > 0 ? headerArgs + "," : headerArgs + const deserializer = DeserializerFactory + const destroy_prefix = DESTROY_PREFIX const frontendHookStr = ` async (${headerArgs} $__callback__$) => { @@ -231,12 +240,12 @@ export class RPCSocket implements I if(r){ if(r.uuid){ $__callback__$['destroy'] = () => { - this.socket.fire('destroy_'+r.uuid) + this.socket.fire(destroy_prefix+r.uuid) this.socket.unhook(r.uuid) } this.socket.hook(r.uuid, $__callback__$) } - return r.return + return deserializer.from(r.return) }else{ throw new Error("Empty response") } diff --git a/src/Strings.ts b/src/Strings.ts index b1063ad..a5693c4 100644 --- a/src/Strings.ts +++ b/src/Strings.ts @@ -11,4 +11,6 @@ RPC did not provide a name. \n \n<------------OFFENDING RPC: \n${name} -\n>------------OFFENDING RPC` \ No newline at end of file +\n>------------OFFENDING RPC` +export const CLASSNAME_ATTRIBUTE = "$__CLASSNAME__$" +export const DESTROY_PREFIX = "$__DESTROY__$_" \ No newline at end of file diff --git a/src/Types.ts b/src/Types.ts index 4b5dcc5..3157f17 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -5,20 +5,20 @@ import { PromiseIO } from "./PromiseIO/Server"; export type PioBindListener = (...args: any) => void export type PioHookListener = AnyFunction -export type AnyFunction = (...args:any) => any +export type AnyFunction = (...args: any[]) => any export type HookFunction = AnyFunction -export type AccessFilter = (sesame:string|undefined, exporter: I.RPCExporter) => Promise | boolean +export type AccessFilter = (sesame: string | undefined, exporter: I.RPCExporter) => Promise | 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 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 SesameConf = { sesame?: string | SesameFunction } export type FrontEndHandlerType = { - 'error' : (e: any) => void - 'close' : () => void + 'error': (e: any) => void + 'close': () => void } export type ClientConfig = SocketIOClient.ConnectOpts & { protocol?: 'http' | 'https', @@ -40,8 +40,8 @@ export type ResponseType = "Subscribe" | "Success" | "Error" export type Outcome = "Success" | "Error" export type Respose = T & { result: Outcome } -export type SuccessResponse = Respose & { result: "Success" } -export type ErrorResponse = Respose & { result: "Error", message?:string } +export type SuccessResponse = Respose & { result: "Success" } +export type ErrorResponse = Respose & { result: "Error", message?: string } export type RPCType = 'Hook' | 'Unhook' | 'Call' @@ -53,17 +53,17 @@ export type CallRPC = { export type HookRPC = { name: Name - hook: Func + hook: AnyFunction onCallback?: AnyFunction onDestroy?: HookCloseFunction extends Promise ? T : ReturnType> } -export type RPC = HookRPC | CallRPC | Func +export type RPC = HookRPC | CallRPC | Func export type RPCInterface = { - [grp in string] : { - [rpc in string] : AnyFunction - } + [grp in string]: { + [rpc in string]: AnyFunction + } } & Impl export type exportT = { @@ -71,7 +71,9 @@ export type exportT = { } export type RPCDefinitions = { - [grp in keyof Ifc]:( { [rpc in keyof Ifc[grp]]: RPC }[keyof Ifc[grp]] )[] + [grp in keyof Ifc]: ({ + [rpc in keyof Ifc[grp]]: RPC + }[keyof Ifc[grp]])[] } export type BaseInfo = { @@ -80,9 +82,9 @@ export type BaseInfo = { argNames: string[], } -export type HookInfo = BaseInfo & { - type: 'Hook', - generator: (socket?:I.Socket) => (...args:any[]) => SubresT +export type HookInfo = BaseInfo & { + type: 'Hook', + generator: (socket?: I.Socket) => (...args: any[]) => SubresT } export type CallInfo = BaseInfo & { @@ -90,15 +92,46 @@ export type CallInfo = BaseInfo & { call: AnyFunction } -export type RpcInfo = HookInfo | CallInfo -export type ExtendedRpcInfo = RpcInfo & { uniqueName: string } +export type RpcInfo = HookInfo | CallInfo +export type ExtendedRpcInfo = RpcInfo & { uniqueName: string } export type OnFunction = (type: T, f: FrontEndHandlerType[T]) => void -export type HookCloseFunction = (res: T, rpc:HookRPC) => any +export type HookCloseFunction = (res: T, rpc: HookRPC) => any -export type AsyncIfc = { [grp in keyof Ifc]: {[rpcname in keyof Ifc[grp]] : AsyncAnyFunction } } +export type AsyncIfc = { [grp in keyof Ifc]: { [rpcname in keyof Ifc[grp]]: AsyncAnyFunction } } -export type AsyncAnyFunction = F extends (...args: Parameters) => infer R - ? ((...args: Parameters) => R extends Promise ? R : Promise ) - : Promise \ No newline at end of file +export type AsyncAnyFunction = F extends (...args: Parameters) => infer R + ? ((...args: Parameters) => R extends Promise ? R : Promise) + : Promise + +type DYN_PARAM< + A = void, + B = void, + C = void, + D = void, + E = void, + F = void, + G = void, + H = void, +> = H extends void ? + G extends void ? + F extends void ? + E extends void ? + D extends void ? + C extends void ? + B extends void ? + A extends void ? + [] + : [A] + : [A,B] + :[A,B,C] + :[A,B,C,D] + :[A,B,C,D,E] + :[A,B,C,D,E,F] + :[A,B,C,D,E,F,G] + :[A,B,C,D,E,F,G,H] + +type Destroyable = { destroy: () => void } +export type Callback = + (this: Destroyable, ...args: DYN_PARAM) => void diff --git a/src/Utils.ts b/src/Utils.ts index dd9857b..d0ebd41 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -3,7 +3,8 @@ import * as uuidv4 from "uuid/v4" import * as T from "./Types"; import * as I from "./Interfaces"; import { Socket } from "socket.io" -import { CALL_NOT_FOUND, RPC_BAD_TYPE, RPC_NO_NAME } from "./Strings"; +import { CALL_NOT_FOUND, DESTROY_PREFIX, RPC_BAD_TYPE, RPC_NO_NAME } from "./Strings"; +import { DeserializerFactory } from "./Decorator"; /** * Translate an RPC to RPCInfo for serialization. @@ -79,10 +80,12 @@ const callGenerator = (rpcName: string, $__socket__$: I.Socket, rpcFunction: T.A const argsArr = extractArgs(rpcFunction) const args = argsArr.join(',') const argsStr = argsArr.map(stripAfterEquals).join(',') + const deserializer = DeserializerFactory const callStr = `async (${args}) => { try{ - return await rpcFunction(${argsStr}) + const res = await rpcFunction(${argsStr}) + return deserializer.makeDeserializable(res) }catch(e){ errorHandler($__socket__$, e, rpcName, [${args}]) } @@ -113,7 +116,7 @@ const hookGenerator = (rpc: T.HookRPC, errorHandler: T.ErrorHandler, s : callArgs callArgs = appendComma(callArgs, false) - + const destroy_prefix = DESTROY_PREFIX const hookStr = ` ($__socket__$) => async (${args}) => { try{ @@ -123,7 +126,7 @@ const hookGenerator = (rpc: T.HookRPC, errorHandler: T.ErrorHandler, s ${rpc.onCallback ? `rpc.onCallback.apply({}, cbargs)` : ``} $__socket__$.call.apply($__socket__$, [uuid, ...cbargs]) }) - ${rpc.onDestroy ? `$__socket__$.bind('destroy_'+uuid, () => { + ${rpc.onDestroy ? `$__socket__$.bind(destroy_prefix+uuid, () => { rpc.onDestroy(res, rpc) })` : ``} return {'uuid': uuid, 'return': res} @@ -144,7 +147,7 @@ const makeError = (callName: string) => new Error(CALL_NOT_FOUND(callName)) */ const extractArgs = (f: Function): string[] => { let fn: string - fn = (fn = String(f)).substr(0, fn.indexOf(")")).substr(fn.indexOf("(") + 1) + fn = (fn = String(f)).substring(0, fn.indexOf(")")).substring(fn.indexOf("(") + 1) return fn !== "" ? fn.split(',') : [] } diff --git a/test/Test.ts b/test/Test.ts index 2431493..ecf2daf 100644 --- a/test/Test.ts +++ b/test/Test.ts @@ -1,7 +1,7 @@ import { describe, it } from "mocha"; -import { RPCServer, RPCSocket } from '../Index' +import { RPCServer, RPCSocket, Serializable } from '../Index' import { RPCExporter, Socket } from "../src/Interfaces"; -import { ConnectedSocket } from "../src/Types"; +import { ConnectedSocket, Callback } from "../src/Types"; import * as log from 'why-is-node-running'; import * as http from 'http'; import * as express from 'express'; @@ -614,7 +614,7 @@ type topicDTO = { topic: string; } type SesameTestIfc = { test: { checkCandy: () => Promise - subscribe: (callback: Function) => Promise + subscribe: (callback: Callback) => Promise manyParams: (a: A, b: B, c: C, d: D) => Promise<[A, B, C, D]> } } @@ -688,7 +688,7 @@ describe('Sesame should unlock the socket', () => { }) it('callback should work with sesame', (done) => { - client.test.subscribe((c) => { + client.test.subscribe(function(c){ if (c === candy) { done() } @@ -1009,5 +1009,83 @@ describe("attaching handlers before connecting", () => { done(e) }) }) - }) + +describe("class (de-)serialization", () => { + + @Serializable() + class SubClass{ + fString = "F" + } + + @Serializable() + class TestClass{ + aString = "A" + aNumber = 46 + aObject = { + x: "x", + y: undefined, + sub: new SubClass() + } + aClassObject = new SubClass() + + public returnOK(){ + return "OK" + } + } + + let myServer: RPCServer; + let mySocket: RPCSocket; + + before(function(done){ + myServer = new RPCServer([{ + name: "Test", + RPCs: [ + function returnClass(){ + return new TestClass() + } + ] + }]) + myServer.listen(8084) + + mySocket = new RPCSocket(8084, 'localhost') + mySocket.connect().then(() => done()) + }) + + after(function(done){ + mySocket.close() + myServer.close() + done() + }) + + + it("receives class in call response", async () => { + const obj: TestClass = await mySocket['Test'].returnClass() + + expect(obj).to.be.an.instanceOf(TestClass) + expect(obj.aString).to.be.a('string') + expect(obj.aNumber).to.be.a('number') + expect(obj.aObject).to.be.a('object') + expect(obj.aObject.x).to.be.a('string') + expect(obj.aObject.y).to.be.undefined + expect(obj.aObject.sub).to.be.an.instanceOf(SubClass) + expect(obj.aClassObject).to.be.an.instanceOf(SubClass) + + expect(obj.returnOK()).to.be.equal('OK') + }) + + it("receives class in hook response", async () => { + const obj: TestClass = await mySocket['Test'].returnClass() + + expect(obj).to.be.an.instanceOf(TestClass) + expect(obj.aString).to.be.a('string') + expect(obj.aNumber).to.be.a('number') + expect(obj.aObject).to.be.a('object') + expect(obj.aObject.x).to.be.a('string') + expect(obj.aObject.y).to.be.undefined + expect(obj.aObject.sub).to.be.an.instanceOf(SubClass) + expect(obj.aClassObject).to.be.an.instanceOf(SubClass) + + expect(obj.returnOK()).to.be.equal('OK') + }) +}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index cfa8ac0..331a89f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,6 @@ "strict": true, "experimentalDecorators": true }, - "include": ["src/**/*.ts", "test/**/*.ts", "Index.ts", "demo.ts", "scratchpad.ts"], + "include": ["src/**/*.ts", "test/**/*.ts", "Index.ts", "demo.ts", "wat.ts"], "exclude": ["node_modules"] } \ No newline at end of file