/** * Safe JavaScript expression evaluator with state context */ export interface EvaluationContext { state: any; } /** * Create a deep proxy that returns safe defaults for undefined properties * * This prevents "undefined" from appearing in expressions when properties don't exist. * Instead of `undefined + "1"` becoming `"undefined1"`, it becomes `"" + "1"` = `"1"`. * * @param state - The state object to wrap * @returns A proxied state object that returns "" for undefined properties * * @example * const safeState = createSafeStateProxy({ page: { user: { name: "Jon" } }, global: {} }); * safeState.page.user.mobileNumber // Returns "" instead of undefined * safeState.page.user.name // Returns "Jon" (existing values unchanged) */ const createSafeStateProxy = (state: any): any => { // Handle null/undefined at the root if (state === null || state === undefined) { return ""; } // Don't proxy primitives or special objects if (typeof state !== 'object' || state instanceof Date || state instanceof RegExp) { return state; } // Handle arrays specially if (Array.isArray(state)) { return new Proxy(state, { get(target, prop) { // Array methods and properties if (prop === 'length' || typeof target[prop as any] === 'function') { return target[prop as any]; } const value = target[prop as any]; // If index exists, return proxied value if (value !== undefined) { if (typeof value === 'object' && value !== null && !(value instanceof Date)) { return createSafeStateProxy(value); } return value; } // Out of bounds index - return empty string return ""; } }); } // Proxy objects return new Proxy(state, { get(target, prop) { const value = target[prop]; // If property exists, return it (wrapped in proxy if it's an object) if (value !== undefined) { // Recursively wrap objects/arrays in proxy if (typeof value === 'object' && value !== null && !(value instanceof Date) && !(value instanceof RegExp)) { return createSafeStateProxy(value); } return value; } // Property is undefined - return empty string as safe default // This prevents "undefined" from appearing in string concatenations return ""; } }); }; /** * Evaluate a JavaScript expression with state context * * @param expression - JavaScript expression as string (e.g., "page.user.age + 1") * @param context - Evaluation context with state object * @returns Evaluated result * @throws Error if evaluation fails * * @example * evaluateExpression("page.user.name", { state: { page: { user: { name: "Jon" } }, global: {} } }) * // Returns: "Jon" * * evaluateExpression("global.counter + 1", { state: { page: {}, global: { counter: 25 } } }) * // Returns: 26 * * evaluateExpression("state.page.user.name.toUpperCase()", { state: { page: { user: { name: "Jon" } }, global: {} } }) * // Returns: "JON" */ export const evaluateExpression = ( expression: string, context: EvaluationContext ): any => { // Validate input if (!expression || typeof expression !== 'string') { throw new Error('Expression must be a non-empty string'); } const trimmedExpression = expression.trim(); if (!trimmedExpression) { throw new Error('Expression cannot be empty'); } try { // Wrap state and aliases in safe proxies that return defaults for undefined properties // This prevents "undefined" from appearing in expressions // Example: state.page.user.mobileNumber returns "" instead of undefined const safeState = createSafeStateProxy(context.state); const safePage = createSafeStateProxy(context?.state?.page ?? {}); const safeGlobal = createSafeStateProxy(context?.state?.global ?? {}); // Create a function with scoped aliases in scope // This is safe because: // 1. This is a builder tool (not end-user facing) // 2. Expressions are written by trusted builder users // 3. Expressions run in isolated preview context const func = new Function('state', 'page', 'global', `return (${trimmedExpression});`); return func(safeState, safePage, safeGlobal); } catch (error: any) { throw new Error(`Expression evaluation failed: ${error?.message || String(error)}`); } }; /** * Validate if an expression is syntactically correct (without executing it) * * @param expression - JavaScript expression to validate * @returns true if valid, false otherwise */ export const validateExpression = (expression: string): boolean => { if (!expression || typeof expression !== 'string') { return false; } const trimmedExpression = expression.trim(); if (!trimmedExpression) { return false; } try { // Try to create the function without executing it new Function('state', 'page', 'global', `return (${trimmedExpression});`); return true; } catch { return false; } };