Integrate async AquaVM into fluence-js (#88)

This commit is contained in:
Pavel
2021-10-20 22:20:43 +03:00
committed by GitHub
parent 727d59fb61
commit fe52648103
35 changed files with 2758 additions and 1739 deletions

View File

@ -0,0 +1,222 @@
/*
* Copyright 2021 Fluence Labs Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CallServiceData, CallServiceResult, CallServiceResultType, ResultCodes } from '../commonTypes';
/**
* @deprecated This class exists to glue legacy RequestFlowBuilder api with restructured async FluencePeer.
* v2 version of compiler support should be used instead
*/
export const callLegacyCallServiceHandler = (
req: CallServiceData,
commonHandler: CallServiceHandler,
particleSpecificHandler?: CallServiceHandler,
): CallServiceResult => {
// trying particle-specific handler
if (particleSpecificHandler !== undefined) {
var res = particleSpecificHandler.execute(req);
}
if (res?.result === undefined) {
// if it didn't return any result trying to run the common handler
res = commonHandler.execute(req);
}
if (res.retCode === undefined) {
res = {
retCode: ResultCodes.unknownError,
result: `The handler did not set any result. Make sure you are calling the right peer and the handler has been registered. Original request data was: serviceId='${req.serviceId}' fnName='${req.fnName}' args='${req.args}'`,
};
}
if (res.result === undefined) {
res.result = null;
}
return res;
};
/**
* @deprecated
* Type for the middleware used in CallServiceHandler middleware chain.
* In a nutshell middleware is a function of request, response and function to trigger the next middleware in chain.
* Each middleware is free to write additional properties to either request or response object.
* When the chain finishes the response is passed back to AVM
* @param { CallServiceData } req - information about the air `call` instruction
* @param { CallServiceResult } resp - response to be passed to AVM
* @param { Function } next - function which invokes next middleware in chain
*/
export type Middleware = (req: CallServiceData, resp: CallServiceResult, next: Function) => void;
/**
* @deprecated
*/
type CallParams = any;
/**
* @deprecated
* Convenience middleware factory. Registers a handler for a pair of 'serviceId/fnName'.
* The return value of the handler is passed back to AVM
* @param { string } serviceId - The identifier of service which would be used to make calls from AVM
* @param { string } fnName - The identifier of function which would be used to make calls from AVM
* @param { (args: any[], tetraplets: SecurityTetraplet[][]) => object } handler - The handler which should handle the call. The result is any object passed back to AVM
*/
export const fnHandler = (
serviceId: string,
fnName: string,
handler: (args: any[], callParams: CallParams) => CallServiceResultType,
) => {
return (req: CallServiceData, resp: CallServiceResult, next: Function): void => {
if (req.fnName === fnName && req.serviceId === serviceId) {
const res = handler(req.args, req.particleContext);
resp.retCode = ResultCodes.success;
resp.result = res;
}
next();
};
};
/**
* @deprecated
* Convenience middleware factory. Registers a handler for a pair of 'serviceId/fnName'.
* Similar to @see { @link fnHandler } but instead returns and empty object immediately runs the handler asynchronously
* @param { string } serviceId - The identifier of service which would be used to make calls from AVM
* @param { string } fnName - The identifier of function which would be used to make calls from AVM
* @param { (args: any[], tetraplets: SecurityTetraplet[][]) => void } handler - The handler which should handle the call.
*/
export const fnAsEventHandler = (
serviceId: string, // force format
fnName: string,
handler: (args: any[], callParams: CallParams) => void,
) => {
return (req: CallServiceData, resp: CallServiceResult, next: Function): void => {
if (req.fnName === fnName && req.serviceId === serviceId) {
setTimeout(() => {
handler(req.args, req.particleContext);
}, 0);
resp.retCode = ResultCodes.success;
resp.result = {};
}
next();
};
};
/**
* @deprecated
*/
type CallServiceFunction = (req: CallServiceData, resp: CallServiceResult) => void;
/**
* @deprecated
* Class defines the handling of a `call` air instruction executed by AVM on the local peer.
* All the execution process is defined by the chain of middlewares - architecture popular among backend web frameworks.
* Each middleware has the form of `(req: Call, resp: CallServiceResult, next: Function) => void;`
* A handler starts with an empty middleware chain and does nothing.
* To execute the handler use @see { @link execute } function
*/
export class CallServiceHandler {
private middlewares: Middleware[] = [];
/**
* Appends middleware to the chain of middlewares
* @param { Middleware } middleware
*/
use(middleware: Middleware): CallServiceHandler {
this.middlewares.push(middleware);
return this;
}
/**
* Removes the middleware from the chain of middlewares
* @param { Middleware } middleware
*/
unUse(middleware: Middleware): CallServiceHandler {
const index = this.middlewares.indexOf(middleware);
if (index !== -1) {
this.middlewares.splice(index, 1);
}
return this;
}
/**
* Combine handler with another one. Combination is done by copying middleware chain from the argument's handler into current one.
* Please note, that current handler's middlewares take precedence over the ones from handler to be combined with
* @param { CallServiceHandler } other - CallServiceHandler to be combined with
*/
combineWith(other: CallServiceHandler): CallServiceHandler {
this.middlewares = [...this.middlewares, ...other.middlewares];
return this;
}
/**
* Convenience method for registering @see { @link fnHandler } middleware
*/
on(
serviceId: string, // force format
fnName: string,
handler: (args: any[], callParams: CallParams) => CallServiceResultType,
): Function {
const mw = fnHandler(serviceId, fnName, handler);
this.use(mw);
return () => {
this.unUse(mw);
};
}
/**
* Convenience method for registering @see { @link fnAsEventHandler } middleware
*/
onEvent(
serviceId: string, // force format
fnName: string,
handler: (args: any[], callParams: CallParams) => void,
): Function {
const mw = fnAsEventHandler(serviceId, fnName, handler);
this.use(mw);
return () => {
this.unUse(mw);
};
}
/**
* Collapses middleware chain into a single function.
*/
buildFunction(): CallServiceFunction {
const result = this.middlewares.reduceRight<CallServiceFunction>(
(agg, cur) => {
return (req, resp) => {
cur(req, resp, () => agg(req, resp));
};
},
(req, res) => {},
);
return result;
}
/**
* Executes the handler with the specified Call request. Return the result response
*/
execute(req: CallServiceData): CallServiceResult {
const res: CallServiceResult = {
retCode: undefined,
result: undefined,
};
this.buildFunction()(req, res);
return res;
}
}

View File

@ -1,5 +1,87 @@
/*
* Copyright 2021 Fluence Labs Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CallServiceHandler } from './LegacyCallServiceHandler';
import { Particle } from '../Particle';
export { FluencePeer } from '../FluencePeer';
export { ResultCodes } from '../../internal/CallServiceHandler';
export { RequestFlow } from '../../internal/RequestFlow';
export { RequestFlowBuilder } from '../../internal/RequestFlowBuilder';
export { CallParams } from '../commonTypes';
export { CallParams, ResultCodes } from '../commonTypes';
/**
* @deprecated This class exists to glue legacy RequestFlowBuilder api with restructured async FluencePeer.
* v2 version of compiler support should be used instead
*/
export interface RequestFlow {
particle: Particle;
handler: CallServiceHandler;
timeout?: () => void;
error?: (reason?: any) => void;
}
/**
* @deprecated This class exists to glue legacy RequestFlowBuilder api with restructured async FluencePeer.
* v2 version of compiler support should be used instead
*/
export class RequestFlowBuilder {
private _ttl?: number;
private _script?: string;
private _configs: any = [];
private _error: (reason?: any) => void = () => {};
private _timeout: () => void = () => {};
build(): RequestFlow {
let h = new CallServiceHandler();
for (let c of this._configs) {
c(h);
}
return {
particle: Particle.createNew(this._script!, this._ttl),
handler: h,
timeout: this._timeout,
error: this._error,
};
}
withTTL(ttl: number): RequestFlowBuilder {
this._ttl = ttl;
return this;
}
handleTimeout(timeout: () => void): RequestFlowBuilder {
this._timeout = timeout;
return this;
}
handleScriptError(reject: (reason?: any) => void): RequestFlowBuilder {
this._error = reject;
return this;
}
withRawScript(script: string): RequestFlowBuilder {
this._script = script;
return this;
}
disableInjections(): RequestFlowBuilder {
return this;
}
configHandler(h: (handler: CallServiceHandler) => void) {
this._configs.push(h);
return this;
}
}

View File

@ -0,0 +1,518 @@
/*
* Copyright 2021 Fluence Labs Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SecurityTetraplet } from '@fluencelabs/avm';
import { match } from 'ts-pattern';
import { CallParams, Fluence, FluencePeer } from '../../index';
import { CallServiceData, GenericCallServiceHandler, CallServiceResult, ResultCodes } from '../commonTypes';
import { Particle } from '../Particle';
export { FluencePeer } from '../FluencePeer';
export { CallParams } from '../commonTypes';
/**
* Represents the Aqua Option type
*/
type OptionalType = {
/**
* Type descriptor. Used for pattern-matching
*/
tag: 'optional';
};
/**
* Represents the void type for functions and callbacks with no return value
*/
type VoidType = {
/**
* Type descriptor. Used for pattern-matching
*/
tag: 'void';
};
/**
* Represents all types other than Optional, Void, Callback and MultiReturn
*/
type PrimitiveType = {
/**
* Type descriptor. Used for pattern-matching
*/
tag: 'primitive';
};
/**
* Represents callbacks used in Aqua function arguments (`func` instruction)
*/
type CallbackType = {
/**
* Type descriptor. Used for pattern-matching
*/
tag: 'callback';
/**
* Callback definition
*/
callback: CallbackDef<OptionalType | PrimitiveType, VoidType | OptionalType | PrimitiveType>;
};
/**
* Represents the return type for functions which return multiple values
*/
type MultiReturnType = {
/**
* Type descriptor. Used for pattern-matching
*/
tag: 'multiReturn';
/**
* The description of types of the return values: Array of either primitive or optional types
*/
returnItems: Array<OptionalType | PrimitiveType>;
};
interface ArgDef<ArgType> {
/**
* The name of the argument in Aqua language
*/
name: string;
/**
* The type of the argument
*/
argType: ArgType;
}
interface CallbackDef<ArgType, ReturnType> {
/**
* Callback argument definitions: the list of ArgDefs
*/
argDefs: Array<ArgDef<ArgType>>;
/**
* Definition of the return type of callback
*/
returnType: ReturnType;
}
interface FunctionBodyDef
extends CallbackDef<
// force new line
OptionalType | PrimitiveType,
VoidType | OptionalType | PrimitiveType
> {
/**
* The name of the function in Aqua language
*/
functionName: string;
}
/**
* Definition of function (`func` instruction) generated by the Aqua compiler
*/
interface FunctionCallDef
extends CallbackDef<
OptionalType | PrimitiveType | CallbackType,
VoidType | OptionalType | PrimitiveType | MultiReturnType
> {
/**
* The name of the function in Aqua language
*/
functionName: string;
/**
* Names of the different entities used in generated air script
*/
names: {
/**
* The name of the relay variable
*/
relay: string;
/**
* The name of the serviceId used load variables at the beginning of the script
*/
getDataSrv: string;
/**
* The name of serviceId is used to execute callbacks for the current particle
*/
callbackSrv: string;
/**
* The name of the serviceId which is called to propagate return value to the generated function caller
*/
responseSrv: string;
/**
* The name of the functionName which is called to propagate return value to the generated function caller
*/
responseFnName: string;
/**
* The name of the serviceId which is called to report errors to the generated function caller
*/
errorHandlingSrv: string;
/**
* The name of the functionName which is called to report errors to the generated function caller
*/
errorFnName: string;
};
}
/**
* Definition of service registration function (`service` instruction) generated by the Aqua compiler
*/
interface ServiceDef {
/**
* Default service id. If the service has no default id the value should be undefined
*/
defaultServiceId?: string;
/**
* List of functions which the service consists of
*/
functions: Array<FunctionBodyDef>;
}
/**
* Convenience function to support Aqua `func` generation backend
* The compiler only need to generate a call the function and provide the corresponding definitions and the air script
*
* @param rawFnArgs - raw arguments passed by user to the generated function
* @param def - function definition generated by the Aqua compiler
* @param script - air script with function execution logic generated by the Aqua compiler
*/
export function callFunction(rawFnArgs: Array<any>, def: FunctionCallDef, script: string) {
const { args, peer, config } = extractFunctionArgs(rawFnArgs, def.argDefs.length);
if (args.length !== def.argDefs.length) {
throw new Error('Incorrect number of arguments. Expecting ${def.argDefs.length}');
}
const promise = new Promise((resolve, reject) => {
const particle = Particle.createNew(script, config?.ttl);
for (let i = 0; i < def.argDefs.length; i++) {
const argDef = def.argDefs[i];
const arg = args[i];
const [serviceId, fnName, cb] = match(argDef.argType)
// for callback arguments we are registering particle-specific callback which executes the passed function
.with({ tag: 'callback' }, (callbackDef) => {
const fn = async (req: CallServiceData): Promise<CallServiceResult> => {
const args = convertArgsFromReqToUserCall(req, callbackDef.callback.argDefs);
// arg is function at this point
const result = await arg.apply(null, args);
let res;
switch (callbackDef.callback.returnType.tag) {
case 'void':
res = {};
break;
case 'primitive':
res = result;
break;
case 'optional':
res = tsToAquaOpt(result);
break;
}
return {
retCode: ResultCodes.success,
result: res,
};
};
return [def.names.callbackSrv, argDef.name, fn] as const;
})
// for optional types we are converting value to array representation in air
.with({ tag: 'optional' }, () => {
const fn = (req: CallServiceData): CallServiceResult => {
// arg is optional at this point
const res = tsToAquaOpt(arg);
return {
retCode: ResultCodes.success,
result: res,
};
};
return [def.names.getDataSrv, argDef.name, fn] as const;
})
// for primitive types wre are simply passing the value
.with({ tag: 'primitive' }, () => {
// arg is primitive at this point
const fn = (req: CallServiceData): CallServiceResult => ({
retCode: ResultCodes.success,
result: arg,
});
return [def.names.getDataSrv, argDef.name, fn] as const;
})
.exhaustive();
// registering handlers for every argument of the function
peer.internals.regHandler.forParticle(particle.id, serviceId, fnName, cb);
}
// registering handler for function response
peer.internals.regHandler.forParticle(particle.id, def.names.responseSrv, def.names.responseFnName, (req) => {
const userFunctionReturn = match(def.returnType)
.with({ tag: 'primitive' }, () => req.args[0])
.with({ tag: 'optional' }, () => aquaOptToTs(req.args[0]))
.with({ tag: 'void' }, () => undefined)
.with({ tag: 'multiReturn' }, (mr) => {
return mr.returnItems.map((x, index) => {
return match(x)
.with({ tag: 'optional' }, () => aquaOptToTs(req.args[index]))
.with({ tag: 'primitive' }, () => req.args[index])
.exhaustive();
});
})
.exhaustive();
setTimeout(() => {
resolve(userFunctionReturn);
}, 0);
return {
retCode: ResultCodes.success,
result: {},
};
});
// registering handler for injecting relay variable
peer.internals.regHandler.forParticle(particle.id, def.names.getDataSrv, def.names.relay, (req) => {
return {
retCode: ResultCodes.success,
result: peer.getStatus().relayPeerId,
};
});
// registering handler for error reporting
peer.internals.regHandler.forParticle(particle.id, def.names.errorHandlingSrv, def.names.errorFnName, (req) => {
const [err, _] = req.args;
setTimeout(() => {
reject(err);
}, 0);
return {
retCode: ResultCodes.success,
result: {},
};
});
// registering handler for particle timeout
peer.internals.regHandler.timeout(particle.id, () => {
reject(`Request timed out for ${def.functionName}`);
});
peer.internals.initiateParticle(particle);
});
// if the function has void type we should resolve immediately for API symmetry with non-void types
// to help with debugging we are returning a promise which can be used to track particle errors
// we cannot return a bare promise because JS will lift it, so returning an array with the promise
if (def.returnType.tag === 'void') {
return Promise.resolve([promise]);
} else {
return promise;
}
}
/**
* Convenience function to support Aqua `service` generation backend
* The compiler only need to generate a call the function and provide the corresponding definitions and the air script
*
* @param args - raw arguments passed by user to the generated function
* @param def - service definition generated by the Aqua compiler
*/
export function registerService(args: any[], def: ServiceDef) {
const { peer, service, serviceId } = extractRegisterServiceArgs(args, def.defaultServiceId);
// Checking for missing keys
const requiredKeys = def.functions.map((x) => x.functionName);
const incorrectServiceDefinitions = Object.keys(service).filter((f) => !(f in requiredKeys));
if (!!incorrectServiceDefinitions.length) {
throw new Error(
`Error registering service ${serviceId}: missing functions: ` +
incorrectServiceDefinitions.map((d) => "'" + d + "'").join(', '),
);
}
for (let singleFunction of def.functions) {
// The function has type of (arg1, arg2, arg3, ... , callParams) => CallServiceResultType | void
const userDefinedHandler = service[singleFunction.functionName];
peer.internals.regHandler.common(serviceId, singleFunction.functionName, async (req) => {
const args = convertArgsFromReqToUserCall(req, singleFunction.argDefs);
const rawResult = await userDefinedHandler.apply(null, args);
const result = match(singleFunction.returnType)
.with({ tag: 'primitive' }, () => rawResult)
.with({ tag: 'optional' }, () => tsToAquaOpt(rawResult))
.with({ tag: 'void' }, () => ({}))
.exhaustive();
return {
retCode: ResultCodes.success,
result: result,
};
});
}
}
/**
* Converts argument from ts representation (value | null) to air representation ([value] | [])
*/
const tsToAquaOpt = (arg: unknown | null): any => {
return arg === null || arg === undefined ? [] : [arg];
};
/**
* Converts argument from air representation ([value] | []) to ts representation (value | null)
*/
const aquaOptToTs = (opt: Array<unknown>) => {
return opt.length === 0 ? null : opt[0];
};
/**
* Converts raw arguments which may contain optional types from air representation to ts representation
*/
const convertArgsFromReqToUserCall = (req: CallServiceData, argDefs: Array<ArgDef<OptionalType | PrimitiveType>>) => {
if (req.args.length !== argDefs.length) {
throwForReq(req, `incorrect number of arguments, expected ${argDefs.length}`);
}
const argsAccountedForOptional = req.args.map((x, index) => {
return match(argDefs[index].argType)
.with({ tag: 'optional' }, () => aquaOptToTs(x))
.with({ tag: 'primitive' }, () => x)
.exhaustive();
});
return [...argsAccountedForOptional, extractCallParams(req, argDefs)];
};
/**
* Extracts Call Params from CallServiceData and forms tetraplets according to generated function definition
*/
const extractCallParams = (
req: CallServiceData,
argDefs: Array<ArgDef<OptionalType | PrimitiveType>>,
): CallParams<any> => {
let tetraplets: { [key in string]: SecurityTetraplet[] } = {};
for (let i = 0; i < req.args.length; i++) {
if (argDefs[i]) {
tetraplets[argDefs[i].name] = req.tetraplets[i];
}
}
const callParams = {
...req.particleContext,
tetraplets,
};
return callParams;
};
/**
* Arguments could be passed in one these configurations:
* [...actualArgs]
* [peer, ...actualArgs]
* [...actualArgs, config]
* [peer, ...actualArgs, config]
*
* This function select the appropriate configuration and returns
* arguments in a structured way of: { peer, config, args }
*/
const extractFunctionArgs = (
args: any[],
numberOfExpectedArgs: number,
): {
peer: FluencePeer;
config?: { ttl?: number };
args: any[];
} => {
let peer: FluencePeer;
let structuredArgs: any[];
let config: any;
if (FluencePeer.isInstance(args[0])) {
peer = args[0];
structuredArgs = args.slice(1, numberOfExpectedArgs + 1);
config = args[numberOfExpectedArgs + 2];
} else {
peer = Fluence.getPeer();
structuredArgs = args.slice(0, numberOfExpectedArgs);
config = args[numberOfExpectedArgs + 1];
}
return {
peer: peer,
config: config,
args: structuredArgs,
};
};
/**
* Arguments could be passed in one these configurations:
* [serviceObject]
* [peer, serviceObject]
* [defaultId, serviceObject]
* [peer, defaultId, serviceObject]
*
* Where serviceObject is the raw object with function definitions passed by user
*
* This function select the appropriate configuration and returns
* arguments in a structured way of: { peer, serviceId, service }
*/
const extractRegisterServiceArgs = (
args: any[],
defaultServiceId?: string,
): { peer: FluencePeer; serviceId: string; service: any } => {
let peer: FluencePeer;
let serviceId: any;
let service: any;
if (FluencePeer.isInstance(args[0])) {
peer = args[0];
} else {
peer = Fluence.getPeer();
}
if (typeof args[0] === 'string') {
serviceId = args[0];
} else if (typeof args[1] === 'string') {
serviceId = args[1];
} else {
serviceId = defaultServiceId;
}
// Figuring out which overload is the service.
// If the first argument is not Fluence Peer and it is an object, then it can only be the service def
// If the first argument is peer, we are checking further. The second argument might either be
// an object, that it must be the service object
// or a string, which is the service id. In that case the service is the third argument
if (!FluencePeer.isInstance(args[0]) && typeof args[0] === 'object') {
service = args[0];
} else if (typeof args[1] === 'object') {
service = args[1];
} else {
service = args[2];
}
return {
peer: peer,
serviceId: serviceId,
service: service,
};
};
function throwForReq(req: CallServiceData, message: string) {
throw new Error(`${message}, serviceId='${req.serviceId}' fnName='${req.fnName}' args='${req.args}'`);
}