a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 16 kB view raw
1/** 2 * Safe expression evaluation using cached Function compiler 3 * 4 * Replaces hand-rolled parser with Function constructor for significant bundle size reduction. 5 * Includes hardened scope proxy to prevent prototype pollution and auto-unwrap signals. 6 */ 7 8import type { Dep, Scope, Signal } from "$types/volt"; 9import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants"; 10import { isSignal } from "./shared"; 11 12/** 13 * Custom error class for expression evaluation failures 14 * 15 * Provides context about which expression failed and the underlying cause. 16 */ 17export class EvaluationError extends Error { 18 public expr: string; 19 public cause: unknown; 20 constructor(expression: string, cause: unknown) { 21 const message = cause instanceof Error ? cause.message : String(cause); 22 super(`Error evaluating "${expression}": ${message}`); 23 this.name = "EvaluationError"; 24 this.expr = expression; 25 this.cause = cause; 26 } 27} 28 29const dangerousProps = new Set(DANGEROUS_PROPERTIES); 30const dangerousGlobals = new Set(DANGEROUS_GLOBALS); 31const safeGlobals = new Set(SAFE_GLOBALS); 32 33interface WrapOptions { 34 unwrapSignals: boolean; 35} 36 37const defaultWrapOptions: WrapOptions = { unwrapSignals: false }; 38const readWrapOptions: WrapOptions = { unwrapSignals: true }; 39 40export type EvaluateOpts = { unwrapSignals?: boolean }; 41 42/** 43 * Check if a property name is dangerous and should be blocked 44 */ 45function isDangerousProperty(key: unknown): boolean { 46 if (typeof key !== "string" && typeof key !== "symbol") { 47 return false; 48 } 49 return dangerousProps.has(String(key)); 50} 51 52/** 53 * Type guard to check if a Dep has a set method (is a Signal vs ComputedSignal) 54 */ 55function hasSetMethod(dep: unknown): dep is Dep & { set: (v: unknown) => void } { 56 return (typeof dep === "object" 57 && dep !== null 58 && "set" in dep 59 && typeof (dep as { set?: unknown }).set === "function"); 60} 61 62/** 63 * Wrap a signal to behave like its value while preserving methods 64 * 65 * Creates a proxy that: 66 * - Returns signal methods (.get, .subscribe, and .set if available) when accessed 67 * - Acts like the unwrapped value for all other operations 68 * - Unwraps nested signals in the value 69 * 70 * Handles both Signal (has set) and ComputedSignal (no set) 71 */ 72function wrapSignal(signal: Signal<unknown>, options: WrapOptions): unknown { 73 const hasSet = hasSetMethod(signal); 74 75 const wrapper: Record<string | symbol, unknown> = { 76 get: signal.get, 77 subscribe: signal.subscribe, 78 valueOf: () => signal.get(), 79 toString: () => String(signal.get()), 80 [Symbol.toPrimitive]: (_hint: string) => signal.get(), 81 }; 82 83 if (hasSet) { 84 wrapper.set = signal.set; 85 } 86 87 return new Proxy(wrapper, { 88 get(target, prop) { 89 if (isDangerousProperty(prop)) { 90 return; 91 } 92 93 if (prop === "get" || prop === "subscribe") { 94 return target[prop]; 95 } 96 97 if (prop === "set" && hasSet) { 98 return target[prop]; 99 } 100 101 if (prop === "valueOf" || prop === "toString" || prop === Symbol.toPrimitive) { 102 return target[prop]; 103 } 104 105 if (prop === Symbol.iterator) { 106 const unwrapped = signal.get(); 107 if (unwrapped && typeof unwrapped === "object" && Symbol.iterator in unwrapped) { 108 return (unwrapped as Iterable<unknown>)[Symbol.iterator].bind(unwrapped); 109 } 110 return; 111 } 112 113 const unwrapped = signal.get(); 114 if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) { 115 const wrapped = wrapValue(unwrapped, options); 116 return (wrapped as Record<string | symbol, unknown>)[prop]; 117 } 118 119 if (unwrapped !== null && unwrapped !== undefined) { 120 const boxed = new Object(unwrapped) as Record<string | symbol, unknown>; 121 const value = Reflect.get(boxed, prop, boxed); 122 123 if (typeof value === "function") { 124 return value.bind(unwrapped); 125 } 126 127 return wrapValue(value, options); 128 } 129 130 return; 131 }, 132 133 has(_target, prop) { 134 if (isDangerousProperty(prop)) { 135 return false; 136 } 137 138 if (prop === "get" || prop === "subscribe") { 139 return true; 140 } 141 142 if (prop === "set" && hasSet) { 143 return true; 144 } 145 146 if (prop === Symbol.iterator) { 147 const unwrapped = signal.get(); 148 return unwrapped !== null && unwrapped !== undefined && typeof unwrapped === "object" 149 && Symbol.iterator in unwrapped; 150 } 151 152 const unwrapped = signal.get(); 153 if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) { 154 return prop in unwrapped; 155 } 156 if (unwrapped !== null && unwrapped !== undefined) { 157 const boxed = new Object(unwrapped) as Record<string | symbol, unknown>; 158 return Reflect.has(boxed, prop); 159 } 160 return false; 161 }, 162 }) as unknown; 163} 164 165/** 166 * Wrap a value to block dangerous property access 167 * 168 * Wraps ALL objects to prevent prototype pollution attacks. 169 * Built-in methods still work because we only block dangerous properties. 170 */ 171function wrapValue(value: unknown, options: WrapOptions = defaultWrapOptions): unknown { 172 if (value === null || value === undefined) { 173 return value; 174 } 175 176 if (isSignal(value)) { 177 if (options.unwrapSignals) { 178 return wrapValue((value as { get: () => unknown }).get(), options); 179 } 180 return wrapSignal(value as Signal<unknown>, options); 181 } 182 183 if (typeof value !== "object" && typeof value !== "function") { 184 return value; 185 } 186 187 return new Proxy(value as object, { 188 get(target, prop) { 189 if (isDangerousProperty(prop)) { 190 return; 191 } 192 193 const result = (target as Record<string | symbol, unknown>)[prop]; 194 195 if (typeof result === "function") { 196 return result.bind(target); 197 } 198 199 return wrapValue(result, options); 200 }, 201 202 set(target, prop, newValue) { 203 if (isDangerousProperty(prop)) { 204 return true; 205 } 206 207 (target as Record<string | symbol, unknown>)[prop] = newValue; 208 return true; 209 }, 210 211 has(target, prop) { 212 if (isDangerousProperty(prop)) { 213 return false; 214 } 215 return prop in target; 216 }, 217 }); 218} 219 220/** 221 * Create a hardened proxy around a scope object 222 * 223 * This proxy: 224 * - Blocks access to dangerous properties (constructor, __proto__, prototype, globalThis) 225 * - Auto-unwraps signals on get (transparent reactivity) 226 * - Only allows access to scope properties and whitelisted globals 227 * - Uses Object.create(null) to prevent prototype chain attacks 228 * - Wraps all returned values to prevent nested dangerous access 229 * 230 * @param scope - The scope object to wrap 231 * @returns Proxied scope with security hardening 232 */ 233function createScopeProxy(scope: Scope, options: WrapOptions = defaultWrapOptions): Scope { 234 const base = Object.create(null) as Scope; 235 236 return new Proxy(base, { 237 get(_target, prop) { 238 const propStr = String(prop); 239 240 if (dangerousGlobals.has(propStr)) { 241 return; 242 } 243 244 if (isDangerousProperty(prop)) { 245 return; 246 } 247 248 if (propStr in scope) { 249 const value = scope[propStr]; 250 return wrapValue(value, options); 251 } 252 253 if (safeGlobals.has(propStr)) { 254 return wrapValue((globalThis as Record<string, unknown>)[propStr], options); 255 } 256 257 return; 258 }, 259 260 set(_target, prop, value) { 261 if (isDangerousProperty(prop)) { 262 return true; 263 } 264 265 const propStr = String(prop); 266 267 if (propStr in scope) { 268 const existing = scope[propStr]; 269 if (isSignal(existing) && hasSetMethod(existing)) { 270 existing.set(value); 271 return true; 272 } 273 } 274 275 scope[propStr] = value; 276 return true; 277 }, 278 279 /** 280 * Always return true to prevent 'with' statement from falling back to outer scope 281 */ 282 has(_target, prop) { 283 if (prop === "$unwrap") { 284 return false; 285 } 286 return true; 287 }, 288 289 ownKeys(_target) { 290 return Object.keys(scope).filter((key) => !isDangerousProperty(key)); 291 }, 292 293 getOwnPropertyDescriptor(_target, prop) { 294 if (isDangerousProperty(prop)) { 295 return; 296 } 297 298 const propStr = String(prop); 299 300 if (propStr in scope) { 301 return { configurable: true, enumerable: true, writable: true, value: scope[propStr] }; 302 } 303 304 return; 305 }, 306 }); 307} 308 309/** 310 * Cache for compiled expression functions 311 * 312 * Key: expression string 313 * Value: compiled function 314 */ 315type CompiledExpr = (scope: Scope, unwrap: (value: unknown) => unknown) => unknown; 316 317const exprCache = new Map<string, CompiledExpr>(); 318 319function isIdentifierStart(char: string): boolean { 320 if (char.length === 0) { 321 return false; 322 } 323 const code = char.charCodeAt(0); 324 return ((code >= 65 && code <= 90) || (code >= 97 && code <= 122) || char === "_" || char === "$"); 325} 326 327function isIdentifierPart(char: string): boolean { 328 if (char.length === 0) { 329 return false; 330 } 331 const code = char.charCodeAt(0); 332 return ((code >= 65 && code <= 90) 333 || (code >= 97 && code <= 122) 334 || (code >= 48 && code <= 57) 335 || char === "_" || char === "$"); 336} 337 338function isWhitespace(char: string): boolean { 339 return char === " " || char === "\n" || char === "\r" || char === "\t"; 340} 341 342function transformExpr(expr: string): string { 343 let result = ""; 344 let index = 0; 345 346 while (index < expr.length) { 347 const char = expr[index]; 348 349 if (char === "!") { 350 const next = expr[index + 1] ?? ""; 351 352 if (next === "=") { 353 result += "!"; 354 index += 1; 355 continue; 356 } 357 358 let cursor = index + 1; 359 while (cursor < expr.length && isWhitespace(expr[cursor])) { 360 cursor += 1; 361 } 362 363 const identStart = expr[cursor] ?? ""; 364 if (!isIdentifierStart(identStart)) { 365 result += "!"; 366 index += 1; 367 continue; 368 } 369 370 let end = cursor + 1; 371 while (end < expr.length && isIdentifierPart(expr.charAt(end))) { 372 end += 1; 373 } 374 375 while (end < expr.length && expr[end] === ".") { 376 const afterDot = expr[end + 1] ?? ""; 377 if (!isIdentifierStart(afterDot)) { 378 break; 379 } 380 end += 2; 381 while (end < expr.length && isIdentifierPart(expr.charAt(end))) { 382 end += 1; 383 } 384 } 385 386 const nextChar = expr[end] ?? ""; 387 if (nextChar === "(") { 388 result += "!"; 389 index += 1; 390 continue; 391 } 392 393 const identifier = expr.slice(cursor, end); 394 result += "!$unwrap(" + identifier + ")"; 395 index = end; 396 continue; 397 } 398 399 if (char === ":" && index > 0) { 400 result += char; 401 index += 1; 402 403 while (index < expr.length && isWhitespace(expr[index])) { 404 result += expr[index]; 405 index += 1; 406 } 407 408 if (index < expr.length && isIdentifierStart(expr[index])) { 409 const identStart = index; 410 let identEnd = identStart + 1; 411 412 while (identEnd < expr.length && isIdentifierPart(expr[identEnd])) { 413 identEnd += 1; 414 } 415 416 let lookahead = identEnd; 417 while (lookahead < expr.length && isWhitespace(expr[lookahead])) { 418 lookahead += 1; 419 } 420 421 const afterIdent = expr[lookahead] ?? ""; 422 if (afterIdent === "," || afterIdent === "}" || lookahead >= expr.length || afterIdent === ")") { 423 const identifier = expr.slice(identStart, identEnd); 424 result += "$unwrap(" + identifier + ")"; 425 index = identEnd; 426 continue; 427 } 428 } 429 430 continue; 431 } 432 433 result += char; 434 index += 1; 435 } 436 437 return result; 438} 439 440function unwrapMaybeSignal(value: unknown): unknown { 441 if (isSignal(value)) { 442 return (value as { get: () => unknown }).get(); 443 } 444 return value; 445} 446 447/** 448 * Compile an expression into a function using the Function constructor 449 * 450 * Uses 'with' statement to allow direct variable access from scope. 451 * The with statement works because we're not in strict mode for the function body, 452 * but the scope proxy ensures safety. 453 * 454 * @param expr - Expression string to compile 455 * @param isStmt - Whether this is a statement (no return) or expression (return value) 456 * @returns Compiled function 457 */ 458function compileExpr(expr: string, isStmt = false): CompiledExpr { 459 const cacheKey = `${isStmt ? "stmt" : "expr"}:${expr}`; 460 461 let fn = exprCache.get(cacheKey); 462 if (fn) { 463 return fn; 464 } 465 466 try { 467 const transformed = transformExpr(expr); 468 if (isStmt) { 469 fn = new Function("$scope", "$unwrap", `with($scope){${transformed}}`) as CompiledExpr; 470 } else { 471 fn = new Function("$scope", "$unwrap", `with($scope){return(${transformed})}`) as CompiledExpr; 472 } 473 exprCache.set(cacheKey, fn); 474 return fn; 475 } catch (error) { 476 throw new EvaluationError(expr, error); 477 } 478} 479 480/** 481 * Unwrap signals at the top level only 482 * 483 * Unwraps direct signals and wrapped signals but preserves object/array structure. 484 * This allows bindings to still track nested signals while unwrapping top-level signal results. 485 */ 486function unwrapSignal(value: unknown): unknown { 487 if (isSignal(value)) { 488 return (value as { get: () => unknown }).get(); 489 } 490 491 if ( 492 value 493 && typeof value === "object" 494 && typeof (value as { get?: unknown }).get === "function" 495 && typeof (value as { subscribe?: unknown }).subscribe === "function" 496 ) { 497 return (value as { get: () => unknown }).get(); 498 } 499 500 return value; 501} 502 503/** 504 * Evaluate an expression against a scope object 505 * 506 * Supports: 507 * - Literals: numbers, strings, booleans, null, undefined 508 * - Operators: +, -, *, /, %, ==, !=, ===, !==, <, >, <=, >=, &&, ||, ! 509 * - Property access: obj.prop, obj['prop'], nested paths 510 * - Ternary: condition ? trueVal : falseVal 511 * - Array/object literals: [1, 2, 3], {key: value} 512 * - Function calls: fn(arg1, arg2) 513 * - Arrow functions: (x) => x * 2 514 * - Signals auto-unwrapped 515 * 516 * @param expr - The expression string to evaluate 517 * @param scope - The scope object containing values 518 * @param opts - Evaluation options. By default, signals are unwrapped for read operations. 519 * Pass { unwrapSignals: false } to keep signals wrapped (needed for event handlers that call .set()) 520 * @returns The evaluated result 521 * @throws EvaluationError if expression is invalid or evaluation fails 522 */ 523export function evaluate(expr: string, scope: Scope, opts?: EvaluateOpts): unknown { 524 try { 525 const fn = compileExpr(expr, false); 526 const wrapOptions = opts?.unwrapSignals === false ? defaultWrapOptions : readWrapOptions; 527 const proxiedScope = createScopeProxy(scope, wrapOptions); 528 const result = fn(proxiedScope, unwrapMaybeSignal); 529 return unwrapSignal(result); 530 } catch (error) { 531 if (error instanceof EvaluationError) { 532 throw error; 533 } 534 if (error instanceof ReferenceError) { 535 return undefined; 536 } 537 throw new EvaluationError(expr, error); 538 } 539} 540 541/** 542 * Evaluate multiple statements against a scope object 543 * 544 * Used for event handlers that may contain multiple semicolon-separated statements. 545 * Statements are executed in order but no return value is captured. 546 * Signals are NOT unwrapped by default to allow calling .set() and other signal methods. 547 * 548 * @param expr - The statement(s) to evaluate 549 * @param scope - The scope object containing values 550 * @throws EvaluationError if evaluation fails 551 */ 552export function evaluateStatements(expr: string, scope: Scope): void { 553 try { 554 const fn = compileExpr(expr, true); 555 const proxiedScope = createScopeProxy(scope, defaultWrapOptions); 556 fn(proxiedScope, unwrapMaybeSignal); 557 } catch (error) { 558 if (error instanceof EvaluationError) { 559 throw error; 560 } 561 throw new EvaluationError(expr, error); 562 } 563}