156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
};
|