a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}