UNPKG

@player-ui/player

Version:

727 lines (612 loc) 22.1 kB
import { SyncWaterfallHook, SyncBailHook } from "tapable-ts"; import { NestedError } from "ts-nested-error"; import { parseExpression } from "./parser"; import * as DEFAULT_EXPRESSION_HANDLERS from "./evaluator-functions"; import { collateAwaitable, isAwaitable, isPromiseLike } from "./async"; import { isExpressionNode } from "./types"; import { isObjectExpression } from "./utils"; import type { ExpressionNode, BinaryOperator, UnaryOperator, ExpressionType, ExpressionContext, ExpressionHandler, } from "./types"; /** a && b -- but handles short cutting if the first value is false */ const andandOperator: BinaryOperator = (ctx, a, b, async) => { return LogicalOperators.and(ctx, a, b, async); }; andandOperator.resolveParams = false; /** a || b -- but with short cutting if first value is true */ const ororOperator: BinaryOperator = (ctx, a, b, async) => { return LogicalOperators.or(ctx, a, b, async); }; ororOperator.resolveParams = false; const DEFAULT_BINARY_OPERATORS: Record<string, BinaryOperator> = { // TODO: A lot of these functions used to do type coercion. Not sure if we want to keep that behavior or not. "+": (a: any, b: any) => a + b, "-": (a: any, b: any) => a - b, "*": (a: any, b: any) => a * b, "/": (a: any, b: any) => a / b, "%": (a: any, b: any) => a % b, // Promise-aware comparison operators // eslint-disable-next-line "==": makePromiseAwareBinaryOp((a: any, b: any) => a == b), // eslint-disable-next-line "!=": makePromiseAwareBinaryOp((a: any, b: any) => a != b), ">": makePromiseAwareBinaryOp((a: any, b: any) => a > b), ">=": makePromiseAwareBinaryOp((a: any, b: any) => a >= b), "<": makePromiseAwareBinaryOp((a: any, b: any) => a < b), "<=": makePromiseAwareBinaryOp((a: any, b: any) => a <= b), "!==": makePromiseAwareBinaryOp((a: any, b: any) => a !== b), "===": makePromiseAwareBinaryOp((a: any, b: any) => a === b), "&&": andandOperator, "||": ororOperator, // eslint-disable-next-line "|": (a: any, b: any) => a | b, // eslint-disable-next-line "&": (a: any, b: any) => a & b, "+=": (a: any, b: any) => a + b, "-=": (a: any, b: any) => a - b, // eslint-disable-next-line "&=": (a: any, b: any) => a & b, // eslint-disable-next-line "|=": (a: any, b: any) => a | b, }; const DEFAULT_UNARY_OPERATORS: Record<string, UnaryOperator> = { "-": (a: any) => -a, "+": (a: any) => Number(a), "!": makePromiseAwareUnaryOp((a: any) => !a), }; /** * Higher-order function that makes any binary operation Promise-aware */ function makePromiseAwareBinaryOp<T>( operation: (a: any, b: any) => T, ): (a: any, b: any, async: boolean) => T | Promise<T> { return (a: any, b: any, async: boolean) => { //async handler if (async && (isAwaitable(a) || isAwaitable(b))) { return collateAwaitable([ Promise.resolve(a), Promise.resolve(b), ]).awaitableThen(([resolvedA, resolvedB]) => operation(resolvedA, resolvedB), ); } //sync handler return operation(a, b); }; } /** * Higher-order function that makes any unary operation Promise-aware */ function makePromiseAwareUnaryOp<T>( operation: (a: any) => T, ): (a: any, async: boolean) => T | Promise<T> { return (a: any, async: boolean) => { //async handler if (async && isAwaitable(a)) { return a.awaitableThen((resolved: any) => operation(resolved)); } //sync handler return operation(a); }; } /** * Utility for handling conditional branching with Promises */ function handleConditionalBranching( testValue: any, getTrueBranch: () => any, getFalseBranch: () => any, resolveNode: (node: any) => any, async: boolean, ): any { //async handler if (async && isAwaitable(testValue)) { return testValue.awaitableThen((resolved: boolean) => { const branch = resolved ? getTrueBranch() : getFalseBranch(); const branchResult = resolveNode(branch); return isAwaitable(branchResult) ? Promise.resolve(branchResult) : branchResult; }); } // sync handler const branch = testValue ? getTrueBranch() : getFalseBranch(); return resolveNode(branch); } /** * Utility for handling collections (arrays/objects) with potential Promises */ const PromiseCollectionHandler = { /** * Handle array with potential Promise elements */ handleArray<T>(items: T[], async: boolean): T[] | Promise<T[]> { if (!async) { return items; } const hasPromises = items.some((item) => isAwaitable(item)); return hasPromises ? collateAwaitable(items) : items; }, /** * Handle object with potential Promise keys/values */ handleObject( attributes: Array<{ key: any; value: any }>, resolveNode: (node: any) => any, async: boolean, ): Record<string, any> | Promise<Record<string, any>> { const resolvedAttributes: Record<string, any> = {}; const promises: Promise<void>[] = []; let hasPromises = false; attributes.forEach((attr) => { const key = resolveNode(attr.key); const value = resolveNode(attr.value); //async handler if (async && (isAwaitable(key) || isAwaitable(value))) { hasPromises = true; const keyPromise = Promise.resolve(key); const valuePromise = Promise.resolve(value); promises.push( collateAwaitable([keyPromise, valuePromise]).awaitableThen( ([resolvedKey, resolvedValue]) => { resolvedAttributes[resolvedKey] = resolvedValue; }, ), ); } else { resolvedAttributes[key] = value; } }); return hasPromises ? collateAwaitable(promises).awaitableThen(() => resolvedAttributes) : resolvedAttributes; }, }; /** * Smart logical operators that handle short-circuiting with Promises */ const LogicalOperators = { and: (ctx: any, leftNode: any, rightNode: any, async: boolean) => { const leftResult = ctx.evaluate(leftNode); if (async && isAwaitable(leftResult)) { return leftResult.awaitableThen((awaitedLeft: any) => { if (!awaitedLeft) return awaitedLeft; // Short circuit const rightResult = ctx.evaluate(rightNode); return isAwaitable(rightResult) ? rightResult : Promise.resolve(rightResult); }); } // Sync short-circuiting return leftResult && ctx.evaluate(rightNode); }, or: (ctx: any, leftNode: any, rightNode: any, async: boolean) => { const leftResult = ctx.evaluate(leftNode); if (async && isAwaitable(leftResult)) { return leftResult.awaitableThen((awaitedLeft: any) => { if (awaitedLeft) return awaitedLeft; // Short circuit const rightResult = ctx.evaluate(rightNode); return isAwaitable(rightResult) ? rightResult : Promise.resolve(rightResult); }); } // Sync short-circuiting return leftResult || ctx.evaluate(rightNode); }, }; export interface HookOptions extends ExpressionContext { /** Given an expression node */ resolveNode: (node: ExpressionNode) => any; /** Enabling this flag skips calling the onError hook, and just throws errors back to the caller. * The caller is responsible for handling the error. */ throwErrors?: boolean; /** Whether expressions should be parsed strictly or not */ strict?: boolean; /** Whether the expression should be evaluated asynchronously */ async?: boolean; } export type ExpressionEvaluatorOptions = Omit< HookOptions, "resolveNode" | "evaluate" >; export type ExpressionEvaluatorFunction = ( exp: ExpressionType, options?: ExpressionEvaluatorOptions, ) => any; /** * The expression evaluator is responsible for parsing and executing anything in the custom expression language * */ export class ExpressionEvaluator { private readonly vars: Record<string, any> = {}; public readonly hooks: { resolve: SyncWaterfallHook<[any, ExpressionNode, HookOptions]>; resolveOptions: SyncWaterfallHook<[HookOptions]>; beforeEvaluate: SyncWaterfallHook<[ExpressionType, HookOptions]>; onError: SyncBailHook<[Error], true>; } = { /** Resolve an AST node for an expression to a value */ resolve: new SyncWaterfallHook<[any, ExpressionNode, HookOptions]>(), /** Gets the options that will be passed in calls to the resolve hook */ resolveOptions: new SyncWaterfallHook<[HookOptions]>(), /** Allows users to change the expression to be evaluated before processing */ beforeEvaluate: new SyncWaterfallHook<[ExpressionType, HookOptions]>(), /** * An optional means of handling an error in the expression execution * Return true if handled, to stop propagation of the error */ onError: new SyncBailHook<[Error], true>(), }; private readonly expressionsCache: Map<string, ExpressionNode> = new Map(); private readonly defaultHookOptions: HookOptions; public readonly operators: { binary: Map<string, BinaryOperator>; unary: Map<string, UnaryOperator>; expressions: Map<string, ExpressionHandler<any, any>>; } = { binary: new Map<string, BinaryOperator>( Object.entries(DEFAULT_BINARY_OPERATORS), ), unary: new Map<string, UnaryOperator>( Object.entries(DEFAULT_UNARY_OPERATORS), ), expressions: new Map<string, ExpressionHandler<any, any>>([ ...Object.entries(DEFAULT_EXPRESSION_HANDLERS), ["await", DEFAULT_EXPRESSION_HANDLERS.waitFor], ]), }; public reset(): void { this.expressionsCache.clear(); } constructor(defaultOptions: ExpressionEvaluatorOptions) { this.defaultHookOptions = { ...defaultOptions, evaluate: (expr) => this.evaluate(expr, this.defaultHookOptions), resolveNode: (node: ExpressionNode) => this._execAST(node, this.defaultHookOptions), }; this.hooks.resolve.tap("ExpressionEvaluator", (result, node, options) => { return this._resolveNode(result, node, options); }); this.evaluate = this.evaluate.bind(this); } public evaluate( expr: ExpressionType, options?: ExpressionEvaluatorOptions, ): any { const resolvedOpts = this.hooks.resolveOptions.call({ ...this.defaultHookOptions, ...options, resolveNode: (node: ExpressionNode) => this._execAST(node, resolvedOpts), }); let expression = this.hooks.beforeEvaluate.call(expr, resolvedOpts) ?? expr; // Unwrap any returned expression type // Since this could also be an object type, we need to recurse through it until we find the end while (isObjectExpression(expression)) { expression = expression.value; } // Check for literals if ( typeof expression === "number" || typeof expression === "boolean" || expression === undefined || expression === null ) { return expression; } // Skip doing anything with objects that are _actually_ just parsed expression nodes if (isExpressionNode(expression)) { return this._execAST(expression, resolvedOpts); } if (Array.isArray(expression)) { return expression.reduce( (_nothing, exp) => this.evaluate(exp, options), null, ); } return this._execString(String(expression), resolvedOpts); } /** * Evaluate functions in an async context * @experimental These Player APIs are in active development and may change. Use with caution */ public evaluateAsync( expr: ExpressionType, options?: ExpressionEvaluatorOptions, ): Promise<any> { // handle async expression block if (Array.isArray(expr)) { return collateAwaitable( expr.map(async (exp) => this.evaluate(exp, { ...options, async: true } as any), ), ).awaitableThen((values) => { return values.pop(); }); } else { return this.evaluate(expr, { ...options, async: true } as any); } } public addExpressionFunction<T extends readonly unknown[], R>( name: string, handler: ExpressionHandler<T, R>, ): void { this.operators.expressions.set(name, handler); } public addBinaryOperator(operator: string, handler: BinaryOperator): void { this.operators.binary.set(operator, handler); } public addUnaryOperator(operator: string, handler: UnaryOperator): void { this.operators.unary.set(operator, handler); } public setExpressionVariable(name: string, value: unknown): void { this.vars[name] = value; } public getExpressionVariable(name: string): unknown { return this.vars[name]; } private _execAST(node: ExpressionNode, options: HookOptions): any { return this.hooks.resolve.call(undefined, node, options); } private _execString(exp: string, options: HookOptions) { if (exp === "") { return exp; } const matches = exp.match(/^@\[(.*)\]@$/); let matchedExp = exp; if (matches) { const [, matched] = Array.from(matches); // In case the expression was surrounded by @[ ]@ if (matched) { matchedExp = matched; } } let storedAST: ExpressionNode; try { storedAST = this.expressionsCache.get(matchedExp) ?? parseExpression(matchedExp, { strict: options.strict }); this.expressionsCache.set(matchedExp, storedAST); } catch (e: any) { if (options.throwErrors || !this.hooks.onError.call(e)) { // Only throw the error if it's not handled by the hook, or throwErrors is true throw new NestedError(`Error parsing expression: ${exp}`, e); } return; } try { return this._execAST(storedAST, options); } catch (e: any) { if (options.throwErrors || !this.hooks.onError.call(e)) { // Only throw the error if it's not handled by the hook, or throwErrors is true throw new NestedError(`Error evaluating expression: ${exp}`, e); } } } private _resolveNode( _currentValue: any, node: ExpressionNode, options: HookOptions, ): unknown { const { resolveNode, model } = options; const isAsync = options.async ?? false; const expressionContext: ExpressionContext = { ...options, evaluate: (expr) => this.evaluate(expr, options), }; if (node.type === "Literal") { return node.value; } if (node.type === "Identifier") { return this.vars[node.name]; } if (node.type === "Compound" || node.type === "ThisExpression") { throw new Error(`Expression type: ${node.type} is not supported`); } if (node.type === "BinaryExpression" || node.type === "LogicalExpression") { const operator = this.operators.binary.get(node.operator); if (operator) { if ("resolveParams" in operator) { if (operator.resolveParams === false) { return operator(expressionContext, node.left, node.right, isAsync); } const left = resolveNode(node.left); const right = resolveNode(node.right); // Handle promises in binary operations if (options.async && (isAwaitable(left) || isAwaitable(right))) { return collateAwaitable([left, right]).awaitableThen( ([leftVal, rightVal]) => operator(expressionContext, leftVal, rightVal, isAsync), ); } return operator(expressionContext, left, right, isAsync); } const left = resolveNode(node.left); const right = resolveNode(node.right); if (options.async && (isAwaitable(left) || isAwaitable(right))) { return collateAwaitable([left, right]).awaitableThen( ([leftVal, rightVal]) => operator(leftVal, rightVal, isAsync), ); } return operator(left, right, isAsync); } return; } if (node.type === "UnaryExpression") { const operator = this.operators.unary.get(node.operator); if (operator) { if ("resolveParams" in operator) { if (operator.resolveParams === false) { return operator(expressionContext, node.argument, isAsync); } const arg = resolveNode(node.argument); if (options.async && isAwaitable(arg)) { return arg.awaitableThen((argVal) => operator(expressionContext, argVal, isAsync), ); } return operator(expressionContext, arg, isAsync); } const arg = resolveNode(node.argument); if (options.async && isAwaitable(arg)) { return arg.awaitableThen((argVal) => operator(argVal, isAsync)); } return operator(arg, isAsync); } return; } if (node.type === "Object") { return PromiseCollectionHandler.handleObject( node.attributes, resolveNode, options.async || false, ); } if (node.type === "CallExpression") { const expressionName = node.callTarget.name; const operator = this.operators.expressions.get(expressionName); if (!operator) { throw new Error(`Unknown expression function: ${expressionName}`); } if ( operator.name === DEFAULT_EXPRESSION_HANDLERS.waitFor.name && !options.async ) { throw new Error("Usage of await outside of async context"); } if ("resolveParams" in operator && operator.resolveParams === false) { return operator(expressionContext, ...node.args); } const args = node.args.map((n) => resolveNode(n)); // Check if any arguments are promises if (options.async) { const hasPromises = args.some(isAwaitable); if (hasPromises) { return collateAwaitable(args).awaitableThen((resolvedArgs) => operator(expressionContext, ...resolvedArgs), ); } } return operator(expressionContext, ...args); } if (node.type === "ModelRef") { return model.get(node.ref, { context: { model: options.model } }); } if (node.type === "MemberExpression") { const obj = resolveNode(node.object); const prop = resolveNode(node.property); if (options.async && (isAwaitable(obj) || isAwaitable(prop))) { return collateAwaitable([obj, prop]).awaitableThen( ([objVal, propVal]) => objVal[propVal], ); } return obj[prop]; } if (node.type === "Assignment") { if (node.left.type === "ModelRef") { const value = resolveNode(node.right); if (isPromiseLike(value)) { if (options.async && isAwaitable(value)) { return value.awaitableThen((resolvedValue) => { model.set([[(node.left as any).ref, resolvedValue]]); return resolvedValue; }); } else { options.logger?.warn( "Unawaited promise written to mode, this behavior is undefined and may change in future releases", ); } } model.set([[(node.left as any).ref, value]]); return value; } if (node.left.type === "Identifier") { const value = resolveNode(node.right); if (options.async && isAwaitable(value)) { return value.awaitableThen((resolvedValue) => { this.vars[(node.left as any).name] = resolvedValue; return resolvedValue; }); } this.vars[(node.left as any).name] = value; return value; } return; } if (node.type === "ConditionalExpression") { const testResult = resolveNode(node.test); return handleConditionalBranching( testResult, () => node.consequent, () => node.alternate, resolveNode, isAsync, ); } if (node.type === "ArrayExpression") { const results = node.elements.map((ele) => resolveNode(ele)); return PromiseCollectionHandler.handleArray(results, isAsync); } if (node.type === "Modification") { const operation = this.operators.binary.get(node.operator); if (operation) { let newValue; if ("resolveParams" in operation) { if (operation.resolveParams === false) { newValue = operation( expressionContext, node.left, node.right, isAsync, ); } else { const left = resolveNode(node.left); const right = resolveNode(node.right); if (options.async && (isAwaitable(left) || isAwaitable(right))) { newValue = collateAwaitable([left, right]).awaitableThen( ([leftVal, rightVal]) => operation(expressionContext, leftVal, rightVal, isAsync), ); } else { newValue = operation(expressionContext, left, right, isAsync); } } } else { const left = resolveNode(node.left); const right = resolveNode(node.right); if (options.async && (isAwaitable(left) || isAwaitable(right))) { newValue = collateAwaitable([left, right]).awaitableThen( ([leftVal, rightVal]) => operation(leftVal, rightVal, isAsync), ); } else { newValue = operation(left, right, isAsync); } } if (node.left.type === "ModelRef") { if (options.async && isAwaitable(newValue)) { return newValue.awaitableThen((resolvedValue) => { model.set([[(node.left as any).ref, resolvedValue]]); return resolvedValue; }); } model.set([[(node.left as any).ref, newValue]]); } else if (node.left.type === "Identifier") { if (options.async && isAwaitable(newValue)) { return newValue.awaitableThen((resolvedValue) => { this.vars[(node.left as any).name] = resolvedValue; return resolvedValue; }); } this.vars[(node.left as any).name] = newValue; } return newValue; } return resolveNode(node.left); } } }