a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: binder & evaluator (#3)

* feat: updated build pipeline

* refactor: signal unwrapping

* docs: reorganize docs

* drafted more detailed internals docs

authored by

Owais and committed by
GitHub
2aae854b 0ef3b3c6

+1555 -1809
+15 -51
README.md
··· 5 5 [![JSR](https://jsr.io/badges/@voltx/core)](https://jsr.io/@voltx/core) 6 6 ![NPM Version](https://img.shields.io/npm/v/voltx.js?logo=npm) 7 7 8 - > ⚠️ **Pre-release Software**: VoltX.js is in active development. Breaking changes are expected until v1.0. Use in production at your own risk. 8 + > ⚠️ **Pre-release Software**: VoltX.js remains in active development. Expect breaking changes until v1.0 and evaluate before using in production. 9 + 10 + Volt is a monorepo centered around the VoltX.js runtime—a lightweight, declarative alternative to component-centric UI frameworks. The repo also ships the Volt CLI and the documentation site that demonstrates and explains the runtime. 9 11 10 12 ## Philosophy/Goals 11 13 ··· 33 35 | Bindings | `data-volt-text`, `data-volt-html`, `data-volt-class` connect attributes or text to expressions. | 34 36 | Actions | `data-volt-on-click`, `data-volt-on-input`, etc. attach event handlers declaratively. | 35 37 | Streams | `data-volt-stream="/events"` listens for SSE or WebSocket updates and applies JSON patches. | 36 - | Plugins | Modular extensions (`data-volt-persist`, `data-volt-animate`, etc.) that enhance the core. | 38 + | Plugins | Modular extensions (`data-volt-persist`, `data-volt-surge`, `data-volt-shift`, etc.) to enhance the core. | 37 39 38 - ## Project Structure 40 + ## Packages 39 41 40 42 ```sh 41 43 volt/ 42 - ├── dev/ 43 - ├── docs/ 44 - ├── examples/ 45 - ├── lib 46 - │ ├── index.html 47 - │ ├── public 48 - │ ├── src 49 - │ │ ├── core 50 - │ │ │ ├── asyncEffect.ts 51 - │ │ │ ├── binder.ts 52 - │ │ │ ├── charge.ts 53 - │ │ │ ├── dom.ts 54 - │ │ │ ├── evaluator.ts 55 - │ │ │ ├── http.ts 56 - │ │ │ ├── lifecycle.ts 57 - │ │ │ ├── plugin.ts 58 - │ │ │ ├── reactive.ts 59 - │ │ │ ├── shared.ts 60 - │ │ │ ├── signal.ts 61 - │ │ │ ├── ssr.ts 62 - │ │ │ └── tracker.ts 63 - │ │ ├── debug 64 - │ │ │ ├── graph.ts 65 - │ │ │ ├── logger.ts 66 - │ │ │ └── registry.ts 67 - │ │ ├── debug.ts 68 - │ │ ├── demo/ 69 - │ │ ├── index.ts 70 - │ │ ├── main.ts 71 - │ │ ├── plugins 72 - │ │ │ ├── persist.ts 73 - │ │ │ ├── scroll.ts 74 - │ │ │ └── url.ts 75 - │ │ ├── styles 76 - │ │ │ ├── index.css 77 - │ │ │ ├── variables.css 78 - │ │ │ ├── typography.css 79 - │ │ │ ├── forms.css 80 - │ │ │ ├── components.css 81 - │ │ │ ├── collections.css 82 - │ │ │ ├── media.css 83 - │ │ │ └── base.css 84 - │ │ └── types 85 - │ │ ├── helpers.ts 86 - │ │ └── volt.d.ts 87 - │ └── test/ 88 - └── README.md 44 + ├── lib/ VoltX.js runtime published to npm (`voltx.js`) and JSR (`@voltx/core`) 45 + ├── dev/ Volt CLI and local tooling 46 + └── docs/ VitePress documentation site 47 + ``` 48 + 49 + ## Getting Started 89 50 90 - ``` 51 + - Runtime usage: see [`lib/README.md`](./lib/README.md) for installation guides and quick-start examples. 52 + - Local development: `pnpm install` then `pnpm --filter lib dev` run package-specific scripts (`build`, `test`, etc.). 53 + - Review [contribution](./CONTRIBUTING.md) guidelines 54 + - Documentation: `pnpm --filter docs docs:dev` launches the VitePress site. 91 55 92 56 ## License 93 57
+12 -36
ROADMAP.md
··· 16 16 | v0.1.0 | ✓ | [Markup Based Reactivity](#markup-based-reactivity) | 17 17 | v0.2.0 | ✓ | [Reactive Attributes & Event Modifiers](#reactive-attributes--event-modifiers) | 18 18 | v0.3.0 | ✓ | [Global State](#global-state) | 19 - | v0.4.0 | | [Animation & Transitions](#animation--transitions) | 20 - | | | [History API Routing Plugin](#history-api-routing-plugin) | 21 - | v0.5.0 | | [Persistence & Offline](#persistence--offline) | 19 + | v0.4.0 | ✓ | [Animation & Transitions](#animation--transitions) | 20 + | v0.5.0 | | [History API Routing Plugin](#history-api-routing-plugin) | 21 + | | ✓ | [Refactor](#evaluator--binder-hardening) | 22 + | | | [Persistence & Offline](#persistence--offline) | 22 23 | | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | 23 24 | v0.6.0 | | [Navigation & History Management](#navigation--history-management) | 24 25 | v0.7.0 | | [Streaming & Patch Engine](#streaming--patch-engine) | ··· 85 86 86 87 **Goal:** Implement store/context pattern 87 88 **Outcome:** Volt.js provides intuitive global state management 88 - **Deliverables:** 89 - - `$origin` - Reference to the root element of the active reactive scope. 90 - - `$scope` - Reference to the current reactive scope object (signals + context). 91 - - `$pulse()` - Defers execution to the next microtask tick after DOM updates. 92 - - Example: `data-volt-on-click="$count++; $pulse(() => console.log('updated'))"` 93 - - `$store` - Accesses global reactive state registered with Volt’s global store. 94 - - Example: `data-volt-text="$store.theme"` 95 - - `$uid(name?)` - Generates a unique, deterministic ID string within the current scope. 96 - - Example: `data-volt-id="$uid('field')"` 97 - - `$probe(expr, fn)` - Imperatively observes a reactive signal or expression within the current scope. 98 - - Example: `data-volt-init="$probe('count', v => console.log(v))"` 99 - - `$pins` - Scoped element references via `data-volt-pin="name"`. Provides an object mapping ref/pin names to DOM nodes. 100 - - Example: `data-volt-on-click="$pins.username.focus()"` 101 - - `$arc(event, detail?)` - Dispatches a native CustomEvent from the current element. 102 - - Example: `data-volt-on-click="$arc('user:save', { id })"` 103 - 104 - ## To-Do 89 + **Summary:** The scope injects helpers like `$origin`, `$scope`, `$pulse`, `$store`, `$uid`, `$probe`, `$pins`, and `$arc`, giving templates access to global state, microtask scheduling, deterministic IDs, element refs, and custom event dispatch without leaving declarative markup. 105 90 106 91 ### Animation & Transitions 107 92 108 93 **Goal:** Add animation primitives for smooth UI transitions with Alpine/Datastar parity. 109 94 **Outcome:** Volt.js enables declarative animations and view transitions alongside reactivity. 110 - **Deliverables:** 111 - - `data-volt-surge` directive with enter/leave transitions 112 - - Transition modifiers (duration, delay, opacity, scale, etc.) 113 - - View Transitions API integration (when available) 114 - - CSS-based transition helpers 115 - - `data-volt-shift` plugin for keyframe animations 116 - - Timing utilities and easing functions 117 - - Integration with `data-volt-if` and `data-volt-show` for automatic transitions 95 + **Summary:** The surge directive ships fade/slide/scale/blur presets with duration and delay overrides, per-phase enter/leave control, and easing helpers, while the shift plugin applies reusable keyframe animations—both composable with `data-volt-if`/`data-volt-show` as showcased in the animations demo. 96 + 97 + ## To-Do 118 98 119 99 ### Streaming & Patch Engine 120 100 ··· 215 195 216 196 ## Parking Lot 217 197 198 + ### Evaluator & Binder Hardening 199 + 200 + All expression evaluation now flows through a cached `new Function` compiler guarded by a hardened scope proxy, with the binder slimmed into a directive registry so plugins self-register while tests verify the sandboxed error surfaces. 201 + 218 202 ## Examples 219 203 220 204 Many of these are ideas, not planned to be implemented ··· 259 243 - System Monitor - CPU/memory graphs, process list, real-time updates 260 244 - Database Client - Table browser, query editor, result grid, export 261 245 - Media Player - File browser, playlists, controls, metadata display 262 - 263 - ## Docs 264 - 265 - - [ ] Document `charge()` bootstrap flow and declarative state/computed attributes (`data-volt-state`, `data-volt-computed:*`). 266 - - [ ] Add async effect guide covering abort signals, debounce/throttle, retries, and `onError` handling. 267 - - [ ] Write lifecycle instrumentation docs for `registerGlobalHook`, `registerElementHook`, `getElementBindings`, and plugin `context.lifecycle` callbacks. 268 - - [ ] Explain `data-volt-bind:*` semantics, especially boolean attribute handling and dependency subscription behavior. 269 - - [ ] Refresh README and overview content to reflect the current module layout.
+1 -1
docs/.vitepress/config.ts
··· 45 45 { text: "Animations & Transitions", link: "/animations" }, 46 46 ], 47 47 }, 48 - { text: "Tutorials", collapsed: false, items: u.scanDir("usage", "/usage") }, 48 + { text: "Usage", collapsed: false, items: u.scanDir("usage", "/usage") }, 49 49 { 50 50 text: "CSS", 51 51 collapsed: false,
docs/animations.md docs/usage/animations.md
docs/bindings.md docs/usage/bindings.md
-57
docs/expressions.md
··· 1 - # Expression Evaluation 2 - 3 - VoltX.js evaluates JavaScript-like expressions in HTML templates using a sandboxed recursive descent parser. 4 - The evaluator is CSP-compliant and does not use `eval()` or `new Function()`. 5 - 6 - ## Supported Syntax 7 - 8 - The expression language supports a subset of JavaScript: 9 - 10 - - Standard literals (numbers, strings, booleans, null, undefined) 11 - - Arithmetic operators (`+`, `-`, `*`, `/`, `%`) 12 - - Comparison operators (`===`, `!==`, `<`, `>`, `<=`, `>=`) 13 - - Logical operators (`&&`, `||`, `!`) 14 - - Ternary operator (`? :`). 15 - 16 - Property access works via dot notation (`user.name`) or bracket notation (`items[0]`). 17 - Method calls are supported on any object, including chaining (`text.trim().toUpperCase()`). 18 - Arrow functions work with single-expression bodies for use in array methods like `filter`, `map`, and `reduce`. 19 - 20 - Array and object literals can be created inline, with spread operator support (`...`) for both arrays and objects. 21 - Signals are automatically unwrapped when referenced in expressions. 22 - 23 - ## Security Model 24 - 25 - The evaluator implements a balanced sandbox that blocks dangerous operations while attempting to preserve flexibility for most use cases. 26 - 27 - ### Blocked Access 28 - 29 - Three property names are unconditionally blocked to prevent prototype pollution: `__proto__`, `constructor`, and `prototype`. 30 - These restrictions apply to all access patterns including dot notation, bracket notation, and object literal keys. 31 - 32 - The following global names are blocked even if present in scope: 33 - `Function`, `eval`, `globalThis`, `window`, `global`, `process`, `require`, `import`, `module`, `exports`. 34 - 35 - ### Allowed Operations 36 - 37 - Standard constructors and utilities remain accessible: `Array`, `Object`, `String`, `Number`, `Boolean`, `Date`, `Math`, `JSON`, `RegExp`, `Map`, `Set`, `Promise`. 38 - 39 - All built-in methods on native types (strings, arrays, objects, etc.) are permitted. Signal methods (`get`, `set`, `subscribe`) are explicitly allowed even though `constructor` is otherwise blocked. 40 - 41 - ### Error Handling 42 - 43 - Expressions containing unsafe operations or syntax errors are caught, logged to the console, and return `undefined` rather than throwing. This prevents malicious or malformed expressions from breaking the application. 44 - 45 - ## Guidelines 46 - 47 - ### Performance 48 - 49 - Expressions are parsed on every evaluation. For optimal performance, keep expressions simple and use computed signals for complex calculations. 50 - The evaluator automatically tracks signal dependencies so only affected bindings re-evaluate when signals change. 51 - 52 - ### Best Practices 53 - 54 - - Use computed signals for logic that appears in multiple bindings or involves expensive operations. 55 - - Never use untrusted user input directly in expressions without validation. 56 - - Prefer simple, readable expressions in templates over complex nested operations. 57 - - Structure your scope data with consistent shapes (or consistent types) to avoid runtime errors.
docs/global-state.md docs/usage/global-state.md
+53
docs/internals/async.md
··· 1 + # Async Effect Internals 2 + 3 + `asyncEffect` orchestrates asynchronous work that reacts to signals. 4 + It combines the signal subscription model with scheduling helpers (debounce/throttle), abort signals, retries, and cleanup delivery. 5 + The implementation lives in `lib/src/core/async-effect.ts`. 6 + 7 + ## Execution lifecycle 8 + 9 + 1. **Subscription** - Each dependency signal registers `scheduleExecution` with `subscribe()`. 10 + The effect runs immediately on creation and whenever any dependency changes. 11 + 2. **Scheduling** - `scheduleExecution` increments a monotonic `executionId`, then applies debounce or throttle rules before invoking `executeEffect`. 12 + 3. **Abort + cleanup** - The previous cleanup function (if any) runs before each new execution. 13 + When `abortable` is true, a shared `AbortController` is aborted prior to cleanup and replaced for the upcoming run. 14 + 4. **Effect body** - The async callback receives the optional `AbortSignal`. 15 + It may return a cleanup function (sync or async). 16 + VoltX stores it so future runs can dispose the previous work. 17 + 5. **Race protection** - The awaited result checks whether its `executionId` still matches the global counter. 18 + If dependencies changed mid-flight, the run is considered stale and discarded. 19 + 6. **Retry loop** - Errors increment a `retryCount`. 20 + While the counter is below `retries`, the effect waits for `retryDelay` (if provided) and reruns the same `executionId`. 21 + Once retries are exhausted VoltX logs the failure and, when `onError` is defined, passes the error alongside a `retry()` callback that resets the counter and schedules a new run. 22 + 23 + ## Scheduling helpers 24 + 25 + - **Debounce** clears and reuses a `setTimeout`, delaying execution until changes stop for `opts.debounce` (in ms). 26 + - **Throttle** tracks the last execution timestamp. 27 + If the window has not expired it schedules a timer to run later and flips `pendingExecution` so only one trailing invocation is queued. 28 + - Both helpers coexist with abort support: any timer-driven execution aborts the previous run before invoking the effect body. 29 + 30 + ## Cleanup guarantees 31 + 32 + - Returning a function from the effect body registers it as the cleanup for the next iteration. 33 + - Abortable effects tip off downstream code through the `AbortSignal`, but cleanup functions still run even if the consumer ignores the signal. 34 + - Disposing the effect (via the returned function) aborts active requests, runs cleanup once, clears pending timers, and unsubscribes from every dependency. 35 + 36 + ## Error handling nuances 37 + 38 + - All cleanup functions are wrapped in try/catch to avoid crashing the reactive loop. 39 + - Retry delays use `setTimeout` so they respect fake timers in Vitest. 40 + - Stale retries bail immediately if the global `executionId` has advanced, preventing duplicate work after rapid dependency changes. 41 + 42 + ## Testing Surface 43 + 44 + `lib/test/core/async-effect.test.ts` covers: 45 + 46 + - Immediate execution and dependency reactivity. 47 + - Cleanup semantics and disposal. 48 + - Abort controller wiring (abort on change, abort on dispose). 49 + - Race protection to ensure stale responses are ignored. 50 + - Debounce and throttle behavior. 51 + - Retry loops, `onError` callbacks, and manual retry invocation. 52 + 53 + These tests rely on fake timers, so implementation details intentionally avoid microtasks for debounce/throttle, favoring `setTimeout` to keep deterministic control over scheduling.
+119
docs/internals/binder-eval.md
··· 1 + # Bindings & Evaluation 2 + 3 + VoltX’s binding layer is the glue between declarative `data-volt-*` attributes and the reactivity primitives that drive them. 4 + Here we explain how the binder walks the DOM, how directives are dispatched, how expressions are compiled and executed, and the guardrails we erected while hardening the evaluator. 5 + 6 + ## Mount Pipeline 7 + 8 + 1. **Scope preparation** - `mount(root, scope)` first injects VoltX’s helper variables (`$store`, `$uid`, `$pins`, `$probe`, etc.) into the caller-provided scope. 9 + Helpers are frozen before exposure so user code cannot tamper with framework utilities. 10 + 2. **Tree walk** - We perform a DOM walk rooted at `root`, skipping subtrees marked with `data-volt-skip`. 11 + Elements cloaked with `data-volt-cloak` are un-cloaked during traversal. 12 + 3. **Attribute collection** - `getVoltAttrs()` extracts `data-volt-*` attributes and normalises modifiers (e.g. `data-volt-on-click.prevent` -> `on-click` with `.prevent`). 13 + 4. **Directive dispatch** - Structural directives (`data-volt-for`, `data-volt-if`) short-circuit the attribute loop because they clone/remove nodes. 14 + Everything else is routed through `bindAttribute()` which: 15 + - Routes `on-*` attributes to the event binding pipeline. 16 + - Routes `bind:*` aliases (e.g. `bind:value`) to attribute binding helpers. 17 + - For colon-prefixed segments (`data-volt-http:get`), hands control to plugin handlers. 18 + - Falls back to the directive registry or plugin registry, then logs an unknown binding warning. 19 + 5. **Lifecycle hooks** - Each bound element fires the global lifecycle callbacks (`beforeMount`, `afterMount`, etc.). 20 + Per-plugin lifecycles are surfaced via `PluginContext.lifecycle`. 21 + 22 + Each directive registers clean-up callbacks so `mount()` can return a disposer that un-subscribes signals, removes event listeners, and runs plugin uninstall hooks. 23 + 24 + ## Directive Registry 25 + 26 + We expose `registerDirective(name, handler)` to allow plugins to self-register. 27 + Core only ships the structural directives and the minimal attribute/event set required for the base runtime. 28 + This keeps the lib bundle slim and allows tree shaking to drop unused features. 29 + 30 + `registerDirective()` is side-effectful at module evaluation time. Optional packages import the binder, call `registerDirective()`, and expose their entry point via Vite’s plugin system. 31 + Consumers that never import the module never pay for its directives. 32 + 33 + ## Expression Compilation 34 + 35 + All binding expressions funnel through `evaluate(expr, scope)` (or `evaluateStatements()` for multi-statement handlers). 36 + The evaluator implements a few layers of defense: 37 + 38 + ### Cached `new Function` 39 + 40 + - Expressions are compiled into functions with `new Function("$scope", "$unwrap", ...)`. 41 + - We wrap execution in a `with ($scope) { ... }` block to preserve ergonomic access to identifiers. 42 + - Compiled functions are cached in a `Map` keyed by the expression string + mode (`expr` vs `stmt`) 43 + Cache hits avoid re-parsing and reduce GC churn. 44 + 45 + ### Hardened Scope Proxy 46 + 47 + `createScopeProxy(scope)` builds an `Object.create(null)` proxy that: 48 + 49 + - Returns `undefined` for dangerous identifiers and properties (`constructor`, `__proto__`, `globalThis`, `Function`, etc.). 50 + - Reuses VoltX’s `wrapValue()` utility to auto-unwrap signals while guarding against prototype pollution. 51 + - Treats setters specially: if a scope entry is a signal, assignments route to `signal.set()`. 52 + - Spoofs `has` so the `with` block never falls through to `globalThis`. 53 + 54 + Every call to `evaluate()` constructs this proxy and iss fast because signals and helpers are stored on the original scope, not the proxy. 55 + 56 + ### Safe Negation & `$unwrap` 57 + 58 + Logical negation (`!signal`) is tricky when signals are proxied objects. 59 + Before compilation we run `transformExpression()` which rewrites top-level `!identifier` patterns into `!$unwrap(identifier)`. 60 + `$unwrap()` dereferences signals without exposing their methods, making boolean coercion reliable even when the underlying value is a reactive proxy or computed signal. 61 + 62 + ### Signal-Aware Wrapping 63 + 64 + `wrapValue()` enforces blocking rules and auto-unwrapping: 65 + 66 + - Signal reads return a small proxy exposing `get`, `set`, and `subscribe` while delegating property reads to the underlying value. 67 + - Nested values re-enter `wrapValue()` so the entire object graph respects the hazardous-key deny list. 68 + - When `unwrapSignals` is enabled (default for read contexts), signal reads return their current value so DOM bindings can treat them like plain data. 69 + - Statement contexts (event handlers, `data-volt-init`) pass `{ unwrapSignals: false }` so authors can still call `count.set()` or `store.set()` directly. 70 + 71 + ### Error Surfacing 72 + 73 + Any runtime error thrown by the compiled function is wrapped in `EvaluationError` which carries the original expression for better debugging. 74 + Reference errors (missing identifiers) return `undefined` to mimic plain JavaScript. 75 + 76 + ## Event Handlers 77 + 78 + `data-volt-on-*` bindings support modifiers (`prevent`, `stop`, `self`, `once`, `window`, `document`, `debounce`, `throttle`, `passive`). Before executing the handler we assemble an `eventScope` that inherits the original scope but adds `$el` and `$event`. Statements run sequentially; the last value is returned. If the handler returns a function we invoke it with the triggering event to mimic inline handler ergonomics (`data-volt-on-click="(ev) => fn(ev)"`). 79 + 80 + Debounce/throttle modifiers wrap the execute function with cancellable helpers. Clean-up hooks clear timers when the element unmounts. 81 + 82 + ## Structural/Control Directives 83 + 84 + ### `data-volt-if` 85 + 86 + - Clones/discards `if` and optional `else` templates. 87 + - Evaluates the condition reactively; dependencies are tracked via `extractDeps()` which scans expressions for signals. 88 + - Supports surge transitions by awaiting `executeSurgeEnter/Leave()` when available. 89 + - Maintains branch state so redundant renders are skipped. Clean-up disposes child mounts when a branch is swapped out. 90 + 91 + ### `data-volt-for` 92 + 93 + - Parses `"item in items"` or `"(item, index) in items"` grammar. 94 + - Uses a placeholder comment to maintain insertion position. 95 + - Re-renders on dependency changes by clearing existing clones and re-mounting with a child scope containing the loop variables. 96 + - Registers per-item clean-up disposers so each clone tears down correctly. 97 + 98 + ## Data Flow & Dependency Tracking 99 + 100 + Reactive updates rely on `updateAndRegister(ctx, update, expr)`: 101 + 102 + 1. Executes the update function immediately for initial DOM synchronisation. 103 + 2. Calls `extractDeps()` to gather signals referenced within the expression (with special handling for `$store.get()` lookups). 104 + 3. Subscribes to each signal and pushes the unsubscribe callback into the directive’s clean-up list. 105 + 106 + This pattern is used by text/html bindings, class/style bindings, show/if/for, and plugin-provided directives. 107 + 108 + ## Challenges & Lessons 109 + 110 + - **Security vs ergonomics** - Moving from a hand-rolled parser to `new Function` simplified expression support but introduced sandboxing risks. 111 + The scope proxy and whitelists were essential to close off prototype pollution and global escape hatches. 112 + - **Signal negation** - `!signal` originally returned `false` because the proxy object was truthy. 113 + The `$unwrap` transformation ensures boolean logic matches user expectations without forcing explicit `.get()` calls. 114 + - **Plugin isolation** - Allowing plugins to register directives meant we had to guarantee that the core binder stays stateless. 115 + Directive handlers receive a `PluginContext` with controlled capabilities so they can integrate without mutating internal machinery. 116 + - **Error visibility** - Swallowing exceptions made debugging inline expressions painful. 117 + `EvaluationError` and consistent logging in directives give developers actionable stack traces while keeping the runtime resilient. 118 + 119 + With these guardrails the binder provides a secure, extensible bridge between declarative templates and VoltX’s reactive runtime.
+63 -172
docs/internals/proxies.md
··· 1 1 # Proxy Objects 2 2 3 - Volt's reactive proxy system implements deep reactivity for objects and arrays. Unlike `signal()` which wraps a single value, `reactive()` creates a transparent proxy where property access feels natural while maintaining full reactivity. 3 + Volt’s `reactive()` helper wraps plain objects and arrays in Proxies that expose deep reactivity while defending against prototype-pollution and sandbox escapes. 4 + This document details how the proxies are constructed, how they integrate with signals, and where the guardrails live. 4 5 5 - ## Core Design 6 + ## Goals 6 7 7 - The reactive system uses JavaScript Proxies to intercept property access and mutations. 8 - Each reactive proxy is backed by the original raw object and a map of signals: one signal per property, created lazily on first access. 9 - 10 - This design provides: 11 - 12 - **Transparency**: Access `obj.count` instead of `obj.count.get()`. The proxy unwraps signals automatically. 13 - 14 - **Deep Reactivity**: Nested objects and arrays are recursively wrapped, so `obj.nested.value` is reactive all the way down. 8 + - **Transparency** - Accessing `state.user.name` should feel like working with a plain object, without explicit `.get()` calls. 9 + - **Deep reactivity** - Nested objects, arrays, and symbols participate automatically. 10 + - **Safety** - Dangerous keys such as `__proto__` or `constructor` are blocked regardless of depth. 11 + - **Single source of truth** - Each raw object maps to exactly one proxy, keeping identity stable and ensuring watchers de-duplicate work. 15 12 16 - **Lazy Signals**: Signals are only created when properties are accessed. An object with 100 properties only creates signals for the properties you actually use. 13 + ## Core Data Structures 17 14 18 - **Native Array Methods**: Array mutators like `push`, `pop`, `splice` work naturally and trigger updates correctly. 15 + Three WeakMaps maintain relationships: 19 16 20 - ## Three WeakMaps 17 + 1. `rawToReactive` - From raw object to proxy. Guarantees we never create two proxies for the same target. 18 + 2. `reactiveToRaw` - Reverse lookup used by `toRaw()` and `isReactive()`. 19 + 3. `targetToSignals` - Maps a raw target to a `Map<key, Signal>`. Each property lazily receives a signal the first time it’s accessed. 21 20 22 - The system uses three WeakMaps to track relationships: 23 - 24 - 1. `reactiveToRaw` maps from reactive proxy to original object. Used by `toRaw()` to unwrap proxies and by `isReactive()` to check if something is already a proxy. 25 - 2. `rawToReactive` maps from original object to reactive proxy. 26 - This ensures only one proxy per object. Calling `reactive()` twice on the same object returns the same proxy instance. 27 - 3. `targetToSignals` maps from target object to a Map of property signals. 28 - Each target has its own Map of `(key: string | symbol) -> Signal`. Signals are created lazily in `getPropertySignal()`. 29 - 30 - WeakMaps allow us to avoid preventing garbage collection of proxies or targets. 21 + WeakMaps ensure metadata is collected with the target; no explicit teardown is required. 31 22 32 23 ## Property Access (get trap) 33 24 34 - When you access `proxy.count`, the get trap: 25 + When a consumer reads `proxy.key`: 35 26 36 - 1. Checks for special keys: `__v_raw` returns the raw target, `__v_isReactive` returns true. These enable introspection. 37 - 2. For arrays, checks if the key is a mutator method (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`, `fill`, `copyWithin`). 38 - If so, returns a wrapper function that applies the mutation to the target, then updates all affected index signals and the length signal. 39 - 3. Gets or creates the signal for this property via `getPropertySignal()`. 40 - 4. Calls `recordDep(sig)` to track this access for dependency tracking (if inside a computed or effect). 41 - 5. Gets the actual value using `Reflect.get()`. 42 - 6. If the value is an object, wraps it recursively with `reactive()` before returning. This provides deep reactivity. 43 - 7. If the value is not an object, returns `sig.get()` which provides the signal's current value. 27 + 1. **Special escape hatches** - `__v_raw` returns the raw target, `__v_isReactive` identifies proxies, and `$voltx_debug` (internal) surfaces debugging helpers. 28 + 2. **Dangerous key check** - Keys like `"constructor"`, `"prototype"`, `"__proto__"`, or `"globalThis"` immediately return `undefined`. 29 + This mirrors the evaluator’s hardened scope rules, keeping user expressions and runtime helpers aligned. 30 + 3. **Array mutators** - If the target is an array and the key is a mutator (`push`, `splice`, etc.), we return a wrapped function that runs the native method then updates all affected signals (indices + `length`). 31 + 4. **Signal retrieval** - `getPropertySignal()` returns/creates the signal for the property and registers it with the dependency tracker. 32 + 5. **Value resolution** - The raw value is read via `Reflect.get()`. 33 + If it’s an object/function, we recursively call `reactive()` so nested access stays reactive. 34 + Otherwise we return `signal.get()` which unwraps the value. 44 35 45 - The subtle difference in step 6-7 is important: for objects, we return the reactive proxy (not the signal value), so you get `obj.nested.prop` not `obj.nested.get().prop`. But dependency tracking still happens because we called `recordDep()` in step 4. 36 + This layered approach means `reactive()` objects are safe to embed in evaluator scopes—the same dangerous keys are filtered and every nested property remains reactive. 46 37 47 38 ## Property Mutation (set trap) 48 39 49 - When you assign `proxy.count = 5`, the set trap: 40 + `proxy.key = value` executes: 50 41 51 - 1. Reads the old value via `Reflect.get()`. 52 - 2. Performs the actual mutation with `Reflect.set()`. 53 - 3. If the value changed (old !== new), gets the property signal and calls `sig.set(value)`. 54 - 4. Returns the result from `Reflect.set()` to indicate success. 42 + 1. Reads the previous value with `Reflect.get()` (needed for equality checks). 43 + 2. Performs the write via `Reflect.set()`. Failures (e.g. frozen objects) surface as normal `false` return values. 44 + 3. If the value changed, updates the property signal with `signal.set(newValue)` triggering downstream computeds/effects. 55 45 56 - This ensures that mutations to the raw object and signal notifications happen atomically. 46 + Assignments to blocked keys (`__proto__`, `constructor`, etc.) are ignored to prevent prototype pollution. 47 + The setter returns `true` so user code doesn’t throw while the runtime remains protected. 57 48 58 - ## Property Deletion (deleteProperty trap) 49 + ## Deletion & Existence 59 50 60 - When you `delete proxy.count`: 51 + - `delete proxy.key` removes the property via `Reflect.deleteProperty()`, then sets the property signal to `undefined` to notify dependents. 52 + - The `in` operator (`'key' in proxy`) records a dependency and defers to `Reflect.has()`, making existence checks reactive. 61 53 62 - 1. Checks if the property existed with `Reflect.has()`. 63 - 2. Performs the deletion with `Reflect.deleteProperty()`. 64 - 3. If the property existed and deletion succeeded, gets the property signal and sets it to `undefined`. 65 - 4. Returns the deletion result. 54 + ## Array Handling 66 55 67 - Setting the signal to `undefined` ensures any computeds or effects depending on that property get notified about the deletion. 56 + Array methods are wrapped to keep per-index signals in sync: 68 57 69 - ## Property Existence (has trap) 58 + - We snapshot the array’s pre-mutation length. 59 + - Call the native method on the raw array. 60 + - Compute the range of indices that may have changed and update/create their signals. 61 + - Update the `length` signal if needed. 70 62 71 - The `in` operator (`'count' in proxy`) goes through the has trap: 63 + Methods that do not mutate (e.g. `slice`) pass through unwrapped. 72 64 73 - 1. Gets the property signal (creating it if needed). 74 - 2. Records the dependency with `recordDep()`. 75 - 3. Returns `Reflect.has()` result. 65 + ## Integration with Signals 76 66 77 - This makes property existence checks reactive. If you have a computed like `() => 'count' in state`, it will rerun if that property is added or deleted. 67 + Every reactive property is backed by a `signal`. This keeps the proxy layer thin—core logic lives in `signal.ts`, and the proxy simply orchestrates reads/writes against those signals. 68 + Because signals already integrate with dependency tracking, reactive object reads automatically wire into computeds, effects, and DOM bindings without extra bookkeeping. 78 69 79 - ## Array Mutators 70 + ## Interop Utilities 80 71 81 - Arrays get special handling because native methods like `push` mutate multiple indices simultaneously. The array mutator wrapper: 72 + - `toRaw(value)` unwraps a proxy by consulting `reactiveToRaw`. 73 + Useful when passing data to libraries that cannot handle proxies. 74 + - `isReactive(value)` checks presence in `reactiveToRaw` without triggering getters. 75 + - `markRaw(value)` (internal helper) can flag objects that should bypass reactivity, useful for expensive third-party instances. 82 76 83 - 1. Gets the array method from the target (e.g., `Array.prototype.push`). 84 - 2. Records the old length. 85 - 3. Applies the method to the target with the provided arguments. 86 - 4. Calculates the new length and determines the maximum index that might have changed. 87 - 5. Loops through all potentially affected indices, gets/creates their signals, and updates them with the new values from the target. 88 - 6. If the length changed, updates the length signal. 89 - 7. Returns the result from the native method. 77 + ## Evaluator Interaction 90 78 91 - This approach ensures that mutations like `arr.splice(1, 2, 'a', 'b', 'c')` correctly update all affected index signals and the length signal, triggering computeds that depend on those indices. 79 + Bindings and expressions run via the hardened evaluator. When it encounters a reactive proxy it uses `wrapValue()` to create a safe view: 92 80 93 - The mutator list is hardcoded as a Set: `['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin']`. 94 - Array methods that don't mutate (like `map`, `filter`, `slice`, `toSorted`) go through the normal get trap and return the native method bound to the target. 81 + - Dangerous keys remain blocked. 82 + - Signals returned from proxy properties expose `get`, `set`, and `subscribe`, but property reads on the signal proxy delegate back to the underlying value. 83 + - Primitive coercion works because the wrapper defines `valueOf`, `toString`, and `Symbol.toPrimitive` on demand. 84 + - Boolean negation (`!signal`) is rewritten to `!$unwrap(signal)` before compilation so reactive values behave like plain booleans. 95 85 96 - ## Signal Creation 86 + ## Challenges & Lessons 97 87 98 - The `getPropertySignal()` helper: 99 - 100 - 1. Gets the Map of signals for this target from `targetToSignals`, or creates one if needed. 101 - 2. Looks up the signal for this specific key in the Map. 102 - 3. If no signal exists, reads the initial value from the target with `Reflect.get()`, creates a new signal with that value, and stores it in the Map. 103 - 4. Returns the signal. 104 - 105 - This lazy creation means that accessing a property for the first time has a small overhead (creating the signal and Map entries), but subsequent accesses just look up the existing signal. 106 - 107 - ## Integration with Signal System 108 - 109 - The reactive proxy system sits on top of the signal system. Each reactive property is backed by a signal created via `signal()` from the core signal module. 110 - When you access `proxy.count`, you're actually calling `get()` on the signal for the 'count' property. When you assign `proxy.count = 5`, you're calling `set(5)` on that signal. 111 - This means all the signal behavior works: dependency tracking via `recordDep()`, subscriber notifications, equality checks (`value === newValue` to prevent unnecessary updates). 112 - The proxy just provides a more convenient API so you'll use something like `obj.count++` instead of `obj.count.set(obj.count.get() + 1)`. 88 + - **Balancing safety and ergonomics** - Blocking hazardous keys everywhere (reactive proxies, evaluator proxies, and scope helpers) keeps the mental model consistent. 89 + The challenge is ensuring legitimate use cases (e.g. accessing an object’s prototype intentionally) are still possible via `toRaw()` when absolutely required. 90 + - **Array performance** - Naively re-wrapping arrays each mutation was costly. 91 + Hard-coding mutator wrappers keeps hot paths predictable without introducing per-access allocations. 92 + - **Equality semantics** - Since signals use strict equality, mutating nested objects in place does not trigger updates. 93 + Documentation and lint rules encourage immutable patterns or explicit reassignments to keep changes observable. 94 + - **Garbage collection** - Using WeakMaps exclusively avoids memory leaks, but it meant giving up on certain debugging tricks (storing strong references to proxies). 95 + The debug registry in `lib/src/debug` now mirrors relationships explicitly when debugging is enabled. 113 96 114 - ## Deep Reactivity 115 - 116 - Nested objects are made reactive recursively. When the get trap encounters an object value, it wraps it with `reactive()` before returning. 117 - 118 - The `reactive()` function itself checks `rawToReactive` first, so if that nested object was already wrapped, it returns the existing proxy. This means: 119 - 120 - ```ts 121 - const state = reactive({ nested: { count: 0 } }); 122 - const nested1 = state.nested; 123 - const nested2 = state.nested; 124 - nested1 === nested2; // true, same proxy 125 - ``` 126 - 127 - Arrays within reactive objects are also proxied, and arrays containing objects have those objects proxied when accessed: 128 - 129 - ```ts 130 - const state = reactive({ items: [{ id: 1 }, { id: 2 }] }); 131 - state.items[0].id = 3; // fully reactive 132 - state.items.push({ id: 4 }); // also reactive 133 - ``` 134 - 135 - ## Unwrapping with toRaw 136 - 137 - The `toRaw()` function unwraps a proxy to get the original object. It checks if the value is an object, then looks it up in `reactiveToRaw`. If not found, returns the value as-is (it wasn't a proxy). 138 - 139 - This is useful when you need to pass the raw object to third-party libraries that might not handle proxies well, or when you want to do mutations without triggering reactivity (though this is rarely needed). 140 - 141 - ## Checking Reactivity 142 - 143 - The `isReactive()` function checks if a value is a reactive proxy by testing if it's an object and exists in `reactiveToRaw`. 144 - This is simpler than checking for the `__v_isReactive` property because it doesn't trigger the get trap. 145 - 146 - ## Type Safety 147 - 148 - TypeScript types flow through transparently. If you pass `{ count: number }` to `reactive()`, you get back a reactive object typed as `{ count: number }`. 149 - The proxy is invisible to the type system. This is possible because Proxies preserve the object's interface keeping all properties and methods accessible with the same types. 150 - 151 - ## Performance Characteristics 152 - 153 - - Creating a reactive proxy has minimal overhead, as it just creates the Proxy object and adding two WeakMap entries. 154 - - The first property access has the overhead of creating a signal and Map entries. 155 - - Subsequent property access is very fast 156 - - WeakMap lookup for the signal Map, then Map lookup for the signal, then signal.get(). 157 - - Mutations are similarly fast 158 - - WeakMap lookups plus signal.set(). 159 - - The WeakMaps have zero memory overhead for garbage collection so when a proxy is no longer referenced, all its metadata is collected automatically. 160 - - Arrays are slightly slower due to the mutator wrappers, but still $O(n)$ in the number of affected elements, which matches the native method's complexity. 161 - 162 - ## Edge Cases 163 - 164 - **Non-object values**: `reactive()` logs a warning and returns the value unchanged. 165 - You can't make primitives reactive without wrapping them in a `signal()`. 166 - 167 - **Already reactive**: Calling `reactive()` on a proxy returns the same proxy immediately. 168 - 169 - **Prototype pollution**: The proxy traps use `Reflect` methods which respect the prototype chain naturally. 170 - No special protection is needed because signals are stored in a separate WeakMap, not on the object itself. 171 - 172 - **Symbol properties**: Fully supported. Symbols can be used as property keys and get their own signals just like string keys. 173 - 174 - **Non-enumerable properties**: Work correctly. The get/set traps handle them the same as enumerable properties. 175 - 176 - **Frozen/sealed objects**: Setting properties on frozen objects will fail in `Reflect.set()` and the set trap will return false. 177 - The signal won't be updated, maintaining consistency. 178 - 179 - ## Signal vs. Reactive 180 - 181 - Use `signal()` when: 182 - 183 - - You have a single primitive value 184 - - You want explicit get/set calls 185 - - You're storing a function (functions can't be proxy targets) 186 - 187 - Use `reactive()` when: 188 - 189 - - You have an object with multiple properties 190 - - You want natural property access syntax 191 - - You have nested objects or arrays 192 - - You're integrating with code that expects plain objects 193 - 194 - Both are backed by the same signal primitive. The choice is about API ergonomics, not capability. 195 - 196 - ## Implementation Notes 197 - 198 - The proxy system is implemented in `lib/src/core/reactive.ts` and depends only on `signal()` and `recordDep()` from the tracker. 199 - It's completely independent of the binding system, expression evaluator, and other framework features. 200 - 201 - This separation means you can use reactive objects in any context: 202 - - In the DOM binding system via `data-volt-*` attributes 203 - - Programmatic code with `mount()` 204 - - Standalone without any UI at all. 205 - 206 - The declarative `data-volt-state` attribute in the charge system creates reactive objects from JSON, making deep reactivity available in fully declarative apps without writing any JavaScript. 97 + The proxy layer underpins VoltX’s "plain object" ergonomics while preserving the security posture demanded by the evaluator. Understanding these mechanics helps when extending the runtime or diagnosing subtle update issues.
+92
docs/internals/reactivity.md
··· 1 + # Reactivity Architecture 2 + 3 + VoltX’s reactivity system is built around a small set of primitives—signals, computed signals, and effects—that coordinate via an explicit dependency tracker. 4 + This document explains how those pieces fit together, how updates flow through the system, and the trade-offs we made while hardening the implementation. 5 + 6 + ## Signals 7 + 8 + `signal(initialValue)` returns an object with three methods: 9 + 10 + - `get()` records dependency access (via `recordDep`) and returns the current value. 11 + - `set(next)` performs a referential equality check; if the value changed it notifies subscribers. 12 + - `subscribe(listener)` registers callbacks that fire on every change and returns an unsubscribe function. 13 + 14 + Signals store subscribers in a `Set`, so multiple identical subscriptions are deduplicated. Notifications are delivered over a shallow copy of the subscriber list to guard against mutation during iteration, and errors inside a subscriber are caught and logged so one faulty listener cannot collapse the cascade. 15 + 16 + ## Dependency Tracking 17 + 18 + The tracker module exposes `startTracking(source?)`, `recordDep(dep)`, and `stopTracking()`. 19 + 20 + The active tracking context lives on a stack: 21 + 22 + 1. A computed or effect calls `startTracking(source)` before evaluating its body. 23 + 2. Each signal’s `get()` sees the active context and adds itself to the context’s dependency set. 24 + 3. After the body executes, `stopTracking()` pops the context and returns the unique set of dependencies. 25 + 26 + Cycle detection is enforced by comparing the `source` passed to `startTracking()` with the dependency being recorded. 27 + If they match we throw, preventing self-referential computeds from hanging the system. 28 + 29 + ## Computed Signals 30 + 31 + `computed(fn)` wraps a pure function that may read other signals or computeds. Key behaviours: 32 + 33 + - Lazily initialised on first `get()` or `subscribe()` call (`recompute()` runs only when needed). 34 + - During recomputation we unsubscribe from all previous dependencies, start a new tracking context, run `fn`, then subscribe to the newly discovered dependencies. 35 + - Re-entrancy protection guards against accidental recursive loops by throwing when we detect a recompute while one is already running (often a sign of cyclic dependencies). 36 + - If the derived value changes and there are downstream subscribers we notify them immediately. 37 + 38 + Because a computed emits a Signal-like interface, it can be used anywhere a regular signal appears (e.g. bindings, store, nested computeds). 39 + 40 + ## Effects 41 + 42 + `effect(fn)` is VoltX’s autorun/side effect primitive. Internally it mirrors `computed`: 43 + 44 + 1. Runs `fn` inside a tracking context. 45 + 2. Subscribes to dependencies and re-runs when any change. 46 + 3. Supports cleanups: if `fn` returns a function we call it before the next run (or on disposal). 47 + 48 + The returned disposer clears all subscriptions and performs a final cleanup. 49 + Effects are deliberately eager. They run once on creation so initialisation logic (like attaching event listeners) happens immediately. 50 + 51 + ## Reactive Objects 52 + 53 + While this document focuses on signals, most application code interacts with `reactive()` objects. 54 + These are proxies backed by signals; property reads call `signal.get()`, writes call `signal.set()`. 55 + See [Proxy Objects](./proxies.md) for a detailed discussion. 56 + 57 + ## Scope Helpers 58 + 59 + When a scope is mounted, VoltX injects several helpers that lean on the reactive core: 60 + 61 + - `$pulse(cb)` queues `cb` on the microtask queue. 62 + It’s often used to observe the DOM after reactive updates settle. 63 + - `$probe(expr, cb)` bridges the evaluator and the tracker. 64 + It uses `extractDeps()` to pre-compute dependencies for the expression, subscribes to them, and re-evaluates via `evaluate()` on change. 65 + - `$arc`, `$uid`, `$pins`, `$store`, and `$probe` all use the same subscription mechanics. 66 + When they touch signals they automatically participate in the dependency graph. 67 + 68 + These helpers ensure that advanced patterns (imperative probes, custom event dispatch) stay aligned with reactive guarantees. 69 + 70 + ## Update Propagation 71 + 72 + 1. A signal’s `set()` runs, updates the value, and invokes each subscriber. 73 + 2. Computed subscribers (registered via `dep.subscribe(recompute)`) recompute, so if their value changes they notify their own subscribers. 74 + 3. Effects rerun, repeating their tracking cycle. 75 + 4. DOM bindings registered through `updateAndRegister()` receive the update and perform minimal DOM writes. 76 + 77 + VoltX does not batch updates automatically. Calling `set()` twice in a row will push two notifications. 78 + When batching is needed, use `$pulse` or wrap updates in a custom queue. 79 + 80 + ## Challenges & Trade-offs 81 + 82 + - **Minimal core vs features** - The system intentionally avoids hidden mutation queues or scheduler magic. 83 + This keeps mental models simple but means users must explicitly batch when necessary. 84 + - **Signal identity** - Equality checks are referential. 85 + While fast, it means that mutating nested objects without cloning can bypass change detection unless you touch the signal again. 86 + We emphasises immutable patterns or explicit `set()` calls with copies. 87 + - **Dependency discovery** - Parsing expressions to pre-collect dependencies (`extractDeps`) introduces heuristics (e.g. `$store.get()` handling). 88 + We balance accuracy with performance by focusing on common patterns and falling back to runtime evaluation if static analysis fails. 89 + - **Error resilience** - Subscriber callbacks, cleanup functions, and recompute bodies are wrapped in try/catch to prevent one failure from derailing the reactive loop. 90 + The trade-off is noisy console logs, but the alternative—silently swallowing issues—was harder to debug. 91 + 92 + Despite the lightweight implementation, these primitives provide deterministic, traceable update flows that underpin VoltX’s declarative bindings and plugin ecosystem.
docs/lifecycle.md docs/usage/lifecycle.md
+19 -11
docs/state.md docs/usage/state.md
··· 1 - # State Management 1 + # Reactivity 2 2 3 3 VoltX uses signal-based reactivity for state management. State changes automatically trigger DOM updates without virtual DOM diffing or reconciliation. 4 4 ··· 6 6 7 7 ### Signals 8 8 9 - Signals are the foundation of reactive state. A signal holds a single value that can be read, written, and observed for changes. 9 + Signals are the foundation of reactive state. 10 + A signal holds a single value that can be read, written, and observed for changes. 10 11 11 12 Create signals using the `signal()` function, which returns an object with three methods: 12 13 ··· 14 15 - `set(newValue)` updates the value and notifies subscribers 15 16 - `subscribe(callback)` registers a listener for changes 16 17 17 - Signals use strict equality (`===`) to determine if a value has changed. Setting a signal to its current value will not trigger notifications. 18 + Signals use strict equality (`===`) to determine if a value has changed. 19 + Setting a signal to its current value will not trigger notifications. 18 20 19 21 ### Computed Values 20 22 21 23 Computed signals derive their values from other signals. They automatically track dependencies and recalculate only when those dependencies change. 22 24 23 - The `computed()` function takes a calculation function and a dependency array. The framework ensures computed values stay synchronized with their sources. 25 + The `computed()` function takes a calculation function and a dependency array. 26 + The framework ensures computed values stay synchronized with their sources. 24 27 25 28 Computed values are read-only and should not produce side effects. They exist purely to transform or combine other state. 26 29 ··· 34 37 - Logging or analytics 35 38 - Coordinating multiple signals 36 39 37 - For asynchronous operations, use `asyncEffect()` which handles cleanup of pending operations when dependencies change or the effect is disposed. 40 + For asynchronous operations, use `asyncEffect()` (see [asyncEffect](./usage/async-effect)) which handles cleanup of pending operations when dependencies change or the effect is disposed. 38 41 39 42 ## Declarative State 40 43 ··· 46 49 <div data-volt data-volt-state='{"count": 0, "items": []}'> 47 50 ``` 48 51 49 - The framework automatically converts these values into reactive signals. Nested objects and arrays become reactive, and property access in expressions automatically unwraps signal values. 52 + The framework automatically converts these values into reactive signals. 53 + Nested objects and arrays become reactive, and property access in expressions automatically unwraps signal values. 50 54 51 55 ### Computed Values in Markup 52 56 53 - Derive values declaratively using `data-volt-computed:name` attributes. The name becomes a signal in the scope, and the attribute value is the computation expression: 57 + Derive values declaratively using `data-volt-computed:name` attributes. 58 + The name becomes a signal in the scope, and the attribute value is the computation expression: 54 59 55 60 ```html 56 61 <div data-volt ··· 58 63 data-volt-computed:doubled="count * 2"> 59 64 ``` 60 65 61 - Computed values defined this way follow the same rules as programmatic computed signalsthey track dependencies and update automatically. 66 + Computed values defined this way follow the same rules as programmatic computed signals: they track dependencies and update automatically. 62 67 63 68 ## Programmatic State 64 69 ··· 73 78 74 79 ## Scope and Access 75 80 76 - Each mounted element creates a scope containing its signals and computed values. Bindings access signals by property path relative to their scope. 81 + Each mounted element creates a scope containing its signals and computed values. 82 + Bindings access signals by property path relative to their scope. 77 83 78 84 When using declarative state, the scope is built automatically from `data-volt-state` and `data-volt-computed:*` attributes. 79 85 80 86 When using programmatic mounting, the scope is the object passed as the second argument to `mount()`. 81 87 82 - Bindings can access nested properties, and the evaluator automatically unwraps signal values. Event handlers receive special scope additions: `$el` for the element and `$event` for the event object. 88 + Bindings can access nested properties, and the evaluator automatically unwraps signal values. 89 + Event handlers receive special scope additions: `$el` for the element and `$event` for the event object. 83 90 84 91 ## Signal Methods in Expressions 85 92 ··· 93 100 94 101 ## State Persistence 95 102 96 - Signals can be synchronized with browser storage using the built-in persist plugin. See the plugin documentation for details on localStorage, sessionStorage, and IndexedDB integration. 103 + Signals can be synchronized with browser storage using the built-in persist plugin. 104 + See the plugin documentation (coming soon!) for details on localStorage, sessionStorage, and IndexedDB integration. 97 105 98 106 ## State Serialization 99 107
+113
docs/usage/async-effect.md
··· 1 + # Async Effects 2 + 3 + Volt’s `asyncEffect` helper runs asynchronous workflows whenever one or more signals change. 4 + It handles abort signals, debounce/throttle scheduling, retries, and cleanup so you can focus on data fetching logic instead of wiring. 5 + 6 + ## When to use it 7 + 8 + - Fetching or mutating remote data in response to signal changes. 9 + - Performing background work that should cancel when inputs flip rapidly. 10 + - Retrying transient failures without duplicating boilerplate. 11 + - Triggering imperative side effects (e.g., analytics) that return cleanups. 12 + 13 + ## Basic Example 14 + 15 + In this example, if you change `query` with `query.set("new value")` the effect re-runs. 16 + 17 + ```ts 18 + import { asyncEffect, signal } from "voltx.js"; 19 + 20 + const query = signal(""); 21 + const results = signal([]); 22 + 23 + asyncEffect(async () => { 24 + if (!query.get()) { 25 + results.set([]); 26 + return; 27 + } 28 + 29 + const response = await fetch(`/api/search?q=${encodeURIComponent(query.get())}`); 30 + results.set(await response.json()); 31 + }, [query]); 32 + ``` 33 + 34 + If the effect returns a cleanup function it is invoked before the next execution and on disposal. 35 + 36 + ## Abortable Fetches 37 + 38 + Pass `{ abortable: true }` to receive an `AbortSignal`. 39 + VoltX aborts the previous run each time dependencies change or when you dispose the effect. 40 + 41 + ```ts 42 + asyncEffect( 43 + async (signal) => { 44 + const response = await fetch(`/api/files/${fileId.get()}`, { signal }); 45 + data.set(await response.json()); 46 + }, 47 + [fileId], 48 + { abortable: true }, 49 + ); 50 + ``` 51 + 52 + ## Debounce and Throttle 53 + 54 + - `debounce: number` waits until inputs are quiet for the specified milliseconds. 55 + - `throttle: number` skips executions until the interval has elapsed; the latest change runs once the window closes. 56 + 57 + ```ts 58 + asyncEffect( 59 + async () => { 60 + await saveDraft(documentId.get(), draftBody.get()); 61 + }, 62 + [draftBody], 63 + { debounce: 500 }, 64 + ); 65 + ``` 66 + 67 + Combine `debounce` and `abortable` to cancel in-flight saves when the user keeps typing. 68 + 69 + ## Retry Strategies 70 + 71 + `retries` controls how many times VoltX should re-run the effect after it throws. 72 + `retryDelay` adds a pause between attempts. Use `onError` for custom logging or to expose a manual `retry()` hook. 73 + 74 + ```ts 75 + asyncEffect( 76 + async () => { 77 + const res = await fetch("/api/profile"); 78 + if (!res.ok) throw new Error("Request failed"); 79 + profile.set(await res.json()); 80 + }, 81 + [refreshToken], 82 + { 83 + retries: 3, 84 + retryDelay: 1000, 85 + onError(error, retry) { 86 + toast.error(error.message); 87 + retry(); // optionally kick off another attempt immediately 88 + }, 89 + }, 90 + ); 91 + ``` 92 + 93 + ## Cleanup and disposal 94 + 95 + Hold on to the disposer returned by `asyncEffect` when you need to stop reacting: 96 + 97 + ```ts 98 + const stop = asyncEffect(async () => { 99 + const subscription = await openStream(); 100 + return () => subscription.close(); 101 + }, [channel]); 102 + 103 + window.addEventListener("beforeunload", stop); 104 + ``` 105 + 106 + VoltX automatically runs the cleanup when dependencies change, when the effect retries successfully, and when you call the disposer. 107 + 108 + ## Tips 109 + 110 + - Keep the dependency list stable & wrap derived values in computeds if necessary. 111 + - Throw errors from the effect body to trigger retries or the `onError` callback. 112 + - Prefer `debounce` for text inputs and `throttle` for scroll/resize signals. 113 + - Always check abort signals before committing expensive results when `abortable` is enabled.
+1 -1
docs/usage/counter.md
··· 1 - # Building a Counter 1 + # Counter (Example) 2 2 3 3 This tutorial walks through building a simple counter application to demonstrate VoltX.js fundamentals: reactive state, event handling, computed values, and declarative markup. 4 4
+63
docs/usage/expressions.md
··· 1 + # Expression Evaluation 2 + 3 + VoltX.js evaluates JavaScript-like expressions in HTML templates using a cached `new Function()` compiler wrapped in a hardened scope proxy. 4 + The evaluator compiles each unique expression once, caches the resulting function, and executes it against a sandboxed scope that only exposes explicitly whitelisted globals. 5 + 6 + ## Supported Syntax 7 + 8 + The expression language supports a subset of JavaScript: 9 + 10 + - Standard literals (numbers, strings, booleans, null, undefined) 11 + - Arithmetic operators (`+`, `-`, `*`, `/`, `%`) 12 + - Comparison operators (`===`, `!==`, `<`, `>`, `<=`, `>=`) 13 + - Logical operators (`&&`, `||`, `!`) 14 + - Ternary operator (`? :`). 15 + 16 + Property access works via dot notation (`user.name`) or bracket notation (`items[0]`). 17 + Method calls are supported on any object, including chaining (`text.trim().toUpperCase()`). 18 + Arrow functions work with single-expression bodies for use in array methods like `filter`, `map`, and `reduce`. 19 + 20 + Array and object literals can be created inline, with spread operator support (`...`) for both arrays and objects. 21 + Signals are automatically unwrapped when referenced in expressions. 22 + 23 + ## Security Model 24 + 25 + The evaluator wraps each scope in an `Object.create(null)` proxy that filters dangerous identifiers, unwraps signals safely, and prevents prototype-chain access. 26 + Even though the implementation relies on `new Function()`, the compiled function only ever sees the proxy—never the real `globalThis`. 27 + 28 + ### Blocked Access 29 + 30 + Three property names are unconditionally blocked to prevent prototype pollution: `__proto__`, `constructor`, and `prototype`. 31 + These restrictions apply to all access patterns including dot notation, bracket notation, and object literal keys. 32 + 33 + The following global names are blocked even if present in scope: 34 + `Function`, `eval`, `globalThis`, `window`, `global`, `process`, `require`, `import`, `module`, `exports`. 35 + 36 + ### Allowed Operations 37 + 38 + Standard constructors and utilities remain accessible: `Array`, `Object`, `String`, `Number`, `Boolean`, `Date`, `Math`, `JSON`, `RegExp`, `Map`, `Set`, `Promise`. 39 + 40 + All built-in methods on native types (strings, arrays, objects, etc.) are permitted. 41 + Signal methods (`get`, `set`, `subscribe`) are explicitly allowed even though `constructor` is otherwise blocked. 42 + 43 + ### Error Handling 44 + 45 + Expressions containing unsafe operations or syntax errors are wrapped in an `EvaluationError`. 46 + VoltX logs the error with the original expression for easier debugging and returns `undefined` to keep the UI responsive. 47 + 48 + Boolean negation is rewritten internally (`!foo` becomes `!$unwrap(foo)`) so signals behave like plain values during coercion without leaking signal internals into the template. 49 + 50 + ## Guidelines 51 + 52 + ### Performance 53 + 54 + Expressions are compiled on first use and subsequent evaluations hit the cache. 55 + Keep expressions simple and prefer computed signals for heavy logic—the evaluator already tracks dependencies so only affected bindings re-run. 56 + 57 + ### Best Practices 58 + 59 + - Use computed signals for logic that appears in multiple bindings or involves expensive operations. 60 + - Never use untrusted user input directly in expressions without validation. 61 + - Prefer simple, readable expressions in templates over complex nested operations. 62 + - Structure your scope data with consistent shapes (or consistent types) to avoid runtime errors. 63 + - Remember that event handlers (`data-volt-on-*`) evaluate statements without unwrapping signals; call `signal.set(...)` or `store.set(...)` directly when you need to mutate state.
+26 -6
lib/README.md
··· 1 1 # VoltX.js 2 2 3 + [![codecov](https://codecov.io/gh/stormlightlabs/volt/branch/main/graph/badge.svg)](https://codecov.io/gh/stormlightlabs/volt) 4 + [![JSR](https://jsr.io/badges/@voltx/core)](https://jsr.io/@voltx/core) 5 + ![NPM Version](https://img.shields.io/npm/v/voltx.js?logo=npm) 6 + 3 7 > [!WARNING] 4 8 > VoltX.js is in active development. 5 9 > ··· 9 13 10 14 ## Features 11 15 12 - - Declarative HTML-driven reactivity via `data-volt-*` attributes 13 - - Signal-based state management with automatic DOM updates 14 - - Zero dependencies, under 15 KB gzipped 15 - - No virtual DOM, no build step required 16 - - Server-side rendering and hydration support 17 - - Built-in plugins for persistence, routing, and scroll management 16 + - Declarative, HTML-first reactivity via `data-volt-*` attributes 17 + - Fine-grained signals and effects that update the DOM without a virtual DOM 18 + - Zero runtime dependencies and a sub-15 KB gzipped core 19 + - Built-in transport for SSE/WebSocket streams and JSON Patch updates 20 + - Hydration-friendly rendering with SSR helpers 21 + - Optional CSS design system and debug overlay for inspecting signal graphs 18 22 19 23 ## Installation 20 24 ··· 50 54 </script> 51 55 ``` 52 56 57 + ## Plugins 58 + 59 + - `data-volt-persist` – automatically sync state across reloads and tabs 60 + - `data-volt-url` – keep signals in sync with query params and hashes 61 + - `data-volt-scroll` – manage scroll restoration and anchored navigation 62 + - `data-volt-shift` – trigger reusable keyframe animations 63 + - `data-volt-surge` – apply enter/leave transitions with view-transition support 64 + 65 + Plugins are opt-in and can be combined declaratively or registered programmatically via `charge({ plugins: [...] })`. 66 + 53 67 ## Using CSS 54 68 55 69 Import the optional CSS framework: ··· 67 81 ## Documentation 68 82 69 83 Full documentation available at [https://stormlightlabs.github.io/volt/](https://stormlightlabs.github.io/volt/) 84 + 85 + ## Development 86 + 87 + - `pnpm install` (at the repo root) to bootstrap the workspace 88 + - `pnpm --filter lib dev` runs package scripts such as `build`, `test`, and `typecheck` 89 + - `pnpm dev` spins up the playground in `lib/dev` for interactive testing 70 90 71 91 ## License 72 92
+1
lib/eslint.config.js
··· 27 27 "@typescript-eslint/no-this-alias": "off", 28 28 "unicorn/prefer-ternary": "off", 29 29 "no-console": "off", 30 + "unicorn/prefer-code-point": "off", 30 31 "unicorn/filename-case": ["warn", { 31 32 cases: { pascalCase: true, kebabCase: true }, 32 33 multipleFileExtensions: false,
+3 -2
lib/package.json
··· 22 22 "build": "pnpm build:clean && pnpm build:types && pnpm build:lib && pnpm build:lib:min && pnpm build:css && pnpm build:css:min && pnpm build:finalize", 23 23 "build:clean": "rm -rf dist", 24 24 "build:types": "tsc -p tsconfig.build.json", 25 - "build:lib": "vite build --mode lib", 26 - "build:lib:min": "vite build --mode lib:min", 25 + "build:lib": "vite build --mode lib:voltx && vite build --mode lib:debug", 26 + "build:lib:min": "vite build --mode lib:min:voltx && vite build --mode lib:min:debug", 27 27 "build:css": "postcss src/styles/index.css -o dist/voltx.css", 28 28 "build:css:min": "postcss src/styles/index.css -o dist/voltx.min.css --env production", 29 29 "build:finalize": "node scripts/build-finalize.js", ··· 51 51 "postcss": "^8.5.6", 52 52 "postcss-cli": "^11.0.1", 53 53 "postcss-import": "^16.1.1", 54 + "terser": "^5.44.0", 54 55 "vite": "npm:rolldown-vite@7.1.14" 55 56 } 56 57 }
+70 -27
lib/scripts/build-finalize.js
··· 6 6 * 2. Compress voltx.min.js to voltx.min.js.gz 7 7 * 3. Clean up unwanted files (chunks, assets) 8 8 */ 9 - import { copyFileSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; 9 + import { writeFileSync } from "node:fs"; 10 + import { copyFile, readdir, readFile, unlink, writeFile } from "node:fs/promises"; 10 11 import path from "node:path"; 11 12 import { fileURLToPath } from "node:url"; 12 13 import { createGzip } from "node:zlib"; 14 + import { minify as terserMinify } from "terser"; 15 + 16 + async function doGzip(gzPath, input) { 17 + return new Promise(resolve => { 18 + const gzip = createGzip({ level: 9 }); 19 + const output = []; 20 + gzip.on("data", (chunk) => output.push(chunk)); 21 + gzip.on("end", () => { 22 + writeFileSync(gzPath, Buffer.concat(output)); 23 + const sizes = { 24 + original: (input.length / 1024).toFixed(2), 25 + compressed: (Buffer.concat(output).length / 1024).toFixed(2), 26 + }; 27 + console.log(`✓ Compressed voltx.min.js: ${sizes.original}KB → ${sizes.compressed}KB (gzip)`); 28 + resolve(void 0); 29 + }); 30 + 31 + gzip.write(input); 32 + gzip.end(); 33 + }); 34 + } 35 + 36 + async function minifyJS(code) { 37 + const result = await terserMinify(code, { 38 + compress: { 39 + dead_code: true, 40 + drop_debugger: true, 41 + conditionals: true, 42 + evaluate: true, 43 + booleans: true, 44 + loops: true, 45 + unused: true, 46 + hoist_funs: true, 47 + keep_fargs: false, 48 + hoist_vars: false, 49 + if_return: true, 50 + join_vars: true, 51 + side_effects: true, 52 + }, 53 + mangle: { toplevel: true }, 54 + format: { comments: false }, 55 + }); 56 + 57 + if (!result.code) { 58 + throw new Error("Minification failed - no output generated"); 59 + } 13 60 14 - // TODO: move to dev cli 15 - function main() { 61 + return result.code; 62 + } 63 + 64 + async function main() { 16 65 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 17 66 const distDir = path.resolve(__dirname, "../dist"); 18 67 ··· 21 70 try { 22 71 const indexDts = path.join(distDir, "index.d.ts"); 23 72 const voltxDts = path.join(distDir, "voltx.d.ts"); 24 - copyFileSync(indexDts, voltxDts); 73 + await copyFile(indexDts, voltxDts); 25 74 console.log("✓ Copied index.d.ts → voltx.d.ts"); 26 75 } catch (error) { 27 - console.error("✗ Failed to copy type definitions:", error.message); 76 + if (error instanceof Error) { 77 + console.error("✗ Failed to copy type definitions:", error.message); 78 + } 28 79 process.exit(1); 29 80 } 30 81 31 82 try { 32 83 const minJsPath = path.join(distDir, "voltx.min.js"); 33 84 const gzPath = path.join(distDir, "voltx.min.js.gz"); 34 - 35 - const input = readFileSync(minJsPath); 36 - const gzip = createGzip({ level: 9 }); 37 - const output = []; 38 - 39 - gzip.on("data", (chunk) => output.push(chunk)); 40 - gzip.on("end", () => { 41 - writeFileSync(gzPath, Buffer.concat(output)); 42 - const originalSize = (input.length / 1024).toFixed(2); 43 - const compressedSize = (Buffer.concat(output).length / 1024).toFixed(2); 44 - console.log(`✓ Compressed voltx.min.js: ${originalSize}KB → ${compressedSize}KB (gzip)`); 45 - }); 85 + const input = await readFile(minJsPath); 86 + const minified = await minifyJS(input.toString()); 46 87 47 - gzip.write(input); 48 - gzip.end(); 88 + await writeFile(minJsPath, minified); 89 + await doGzip(gzPath, minified); 49 90 } catch (error) { 50 - console.error("✗ Failed to compress voltx.min.js:", error.message); 91 + if (error instanceof Error) { 92 + console.error("✗ Failed to compress voltx.min.js:", error.message); 93 + } 51 94 process.exit(1); 52 95 } 53 96 54 97 try { 55 - const files = readdirSync(distDir); 56 - // Any files not named voltx* or debug* or images 57 - const unwantedPatterns = [/^(?!voltx|debug).*\.js$/, /\.svg$/, /\.png$/, /\.jpg$/]; 58 - 98 + const files = await readdir(distDir); 99 + const unwantedPatterns = [/\.svg$/, /\.png$/, /\.jpg$/]; 59 100 let cleanedCount = 0; 60 101 for (const file of files) { 61 102 const shouldDelete = unwantedPatterns.some((pattern) => pattern.test(file)); 62 103 if (shouldDelete) { 63 - unlinkSync(path.join(distDir, file)); 104 + await unlink(path.join(distDir, file)); 64 105 console.log(`✓ Removed unwanted file: ${file}`); 65 106 cleanedCount++; 66 107 } ··· 70 111 console.log("✓ No unwanted files to clean"); 71 112 } 72 113 } catch (error) { 73 - console.error("✗ Failed to clean unwanted files:", error.message); 114 + if (error instanceof Error) { 115 + console.error("✗ Failed to clean unwanted files:", error.message); 116 + } 74 117 process.exit(1); 75 118 } 76 119 77 - console.log("\n✨ Build finalization complete!"); 120 + console.log("\nBuild finalization complete!"); 78 121 process.exit(0); 79 122 } 80 123
+32 -24
lib/src/core/binder.ts
··· 17 17 import { BOOLEAN_ATTRS } from "./constants"; 18 18 import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 19 19 import { evaluate } from "./evaluator"; 20 - import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; 21 20 import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 22 21 import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; 23 22 import { getPlugin } from "./plugin"; ··· 25 24 import { createArc, createProbe, createPulse, createUid } from "./scope-vars"; 26 25 import { findScopedSignal, isNil, updateAndRegister } from "./shared"; 27 26 import { getStore } from "./store"; 27 + 28 + /** 29 + * Directive registry for custom bindings 30 + * 31 + * Allows modules (like HTTP) to register directive handlers that can be tree-shaken when not imported. 32 + */ 33 + type DirectiveHandler = (ctx: BindingContext, value: string, modifiers?: Modifier[]) => void; 34 + 35 + const directiveRegistry = new Map<string, DirectiveHandler>(); 36 + 37 + /** 38 + * Register a custom directive handler 39 + * 40 + * Used by optional modules (HTTP, plugins) to register directive handlers that can be tree-shaken when the module is not imported. 41 + * 42 + * @param name - Directive name (without data-volt- prefix) 43 + * @param handler - Handler function that processes the directive 44 + */ 45 + export function registerDirective(name: string, handler: DirectiveHandler): void { 46 + directiveRegistry.set(name, handler); 47 + } 28 48 29 49 /** 30 50 * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. ··· 189 209 case "else": { 190 210 break; 191 211 } 192 - case "get": { 193 - bindGet(ctx, value); 194 - break; 195 - } 196 - case "post": { 197 - bindPost(ctx, value); 198 - break; 199 - } 200 - case "put": { 201 - bindPut(ctx, value); 202 - break; 203 - } 204 - case "patch": { 205 - bindPatch(ctx, value); 206 - break; 207 - } 208 - case "delete": { 209 - bindDelete(ctx, value); 210 - break; 211 - } 212 212 default: { 213 + // Check directive registry first (for HTTP and other optional directives) 214 + const directiveHandler = directiveRegistry.get(baseName); 215 + if (directiveHandler) { 216 + directiveHandler(ctx, value, modifiers); 217 + return; 218 + } 219 + 220 + // Then check plugin registry 213 221 const plugin = getPlugin(baseName); 214 222 if (plugin) { 215 223 execPlugin(plugin, ctx, value, baseName); ··· 426 434 const statements = extractStatements(expr); 427 435 let result: unknown; 428 436 for (const stmt of statements) { 429 - result = evaluate(stmt, eventScope); 437 + result = evaluate(stmt, eventScope, { unwrapSignals: false }); 430 438 } 431 439 432 440 if (typeof result === "function") { ··· 650 658 try { 651 659 const statements = extractStatements(expr); 652 660 for (const stmt of statements) { 653 - evaluate(stmt, ctx.scope); 661 + evaluate(stmt, ctx.scope, { unwrapSignals: false }); 654 662 } 655 663 } catch (error) { 656 664 console.error("Error in data-volt-init:", error); ··· 958 966 ctx.cleanups.push(fn); 959 967 }, 960 968 findSignal: (path) => findScopedSignal(ctx.scope, path), 961 - evaluate: (expr) => evaluate(expr, ctx.scope), 969 + evaluate: (expr, options) => evaluate(expr, ctx.scope, options), 962 970 lifecycle, 963 971 }; 964 972 }
+7 -3
lib/src/core/constants.ts
··· 14 14 15 15 export const DANGEROUS_PROPERTIES = ["__proto__", "prototype", "constructor"]; 16 16 17 + /** 18 + * Dangerous globals that should be blocked from expressions 19 + * 20 + * NOTE: The scope proxy's has trap returns true for ALL properties to prevent the 'with' statement from falling back to outer scope, giving us complete control 21 + */ 17 22 export const DANGEROUS_GLOBALS = [ 18 - "Function", 19 - "eval", 20 - "globalThis", 21 23 "window", 24 + "self", 22 25 "global", 26 + "globalThis", 23 27 "process", 24 28 "require", 25 29 "import",
+30 -12
lib/src/core/dom.ts
··· 4 4 5 5 /** 6 6 * Walk the DOM tree and collect all elements with data-volt-* attributes in document order (parent before children). 7 - * 8 7 * Skips children of elements with data-volt-for or data-volt-if since those will be processed when the parent element is cloned and mounted. 9 8 * 10 9 * @param root - The root element to start walking from ··· 53 52 * @returns Map of attribute names to values (without the data-volt- prefix) 54 53 */ 55 54 export function getVoltAttrs(el: Element): Map<string, string> { 56 - const attributes = new Map<string, string>(); 55 + const attrs = new Map<string, string>(); 57 56 58 - for (const attribute of el.attributes) { 59 - if (attribute.name.startsWith("data-volt-")) { 60 - const name = attribute.name.slice(10); 57 + for (const attr of el.attributes) { 58 + if (attr.name.startsWith("data-volt-")) { 59 + const name = attr.name.slice(10); 61 60 62 - // Skip charge metadata attributes 63 61 if (name === "state" || name.startsWith("computed:")) { 64 62 continue; 65 63 } 66 64 67 - attributes.set(name, attribute.value); 65 + attrs.set(name, attr.value); 68 66 } 69 67 } 70 - 71 - return attributes; 68 + return attrs; 72 69 } 73 70 74 71 /** ··· 104 101 } 105 102 106 103 /** 104 + * Check if value is a wrapped signal (from wrapSignal in evaluator) 105 + */ 106 + function isWrappedSignal(value: unknown): boolean { 107 + return (value !== null 108 + && typeof value === "object" 109 + && typeof (value as { get?: unknown }).get === "function" 110 + && typeof (value as { subscribe?: unknown }).subscribe === "function"); 111 + } 112 + 113 + /** 114 + * Unwrap a value if it's a signal or wrapped signal 115 + */ 116 + function unwrapIfSignal(value: unknown): unknown { 117 + if (isWrappedSignal(value)) { 118 + return (value as { get: () => unknown }).get(); 119 + } 120 + return value; 121 + } 122 + 123 + /** 107 124 * Parse a class binding expression. 108 125 * Supports string values ("active"), object notation ({active: true}), 109 126 * and other primitives (true, false, numbers) which are converted to strings. ··· 115 132 const classes = new Map<string, boolean>(); 116 133 switch (typeof value) { 117 134 case "string": { 118 - for (const className of value.split(/\s+/).filter(Boolean)) { 119 - classes.set(className, true); 135 + for (const cls of value.split(/\s+/).filter(Boolean)) { 136 + classes.set(cls, true); 120 137 } 121 138 break; 122 139 } 123 140 case "object": { 124 141 if (value !== null) { 125 142 for (const [key, value_] of Object.entries(value)) { 126 - classes.set(key, Boolean(value_)); 143 + const unwrapped = unwrapIfSignal(value_); 144 + classes.set(key, Boolean(unwrapped)); 127 145 } 128 146 } 129 147 break;
+383 -739
lib/src/core/evaluator.ts
··· 1 1 /** 2 - * Safe expression evaluation with operators support 2 + * Safe expression evaluation using cached Function compiler 3 3 * 4 - * Implements a recursive descent parser for expressions without using eval(). 5 - * Includes sandboxing to prevent prototype pollution and sandbox escape attacks. 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 6 */ 7 7 8 8 import type { Scope } from "$types/volt"; 9 9 import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants"; 10 - import { isNil, isSignal } from "./shared"; 10 + import { 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 + */ 17 + export 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 + } 11 28 12 29 const dangerousProps = new Set(DANGEROUS_PROPERTIES); 30 + const dangerousGlobals = new Set(DANGEROUS_GLOBALS); 13 31 const safeGlobals = new Set(SAFE_GLOBALS); 14 32 15 - function isSafeProp(key: unknown): boolean { 16 - if (typeof key !== "string" && typeof key !== "number") { 17 - return true; 18 - } 33 + interface WrapOptions { 34 + unwrapSignals: boolean; 35 + } 36 + 37 + const defaultWrapOptions: WrapOptions = { unwrapSignals: false }; 38 + const readWrapOptions: WrapOptions = { unwrapSignals: true }; 19 39 20 - const keyStr = String(key); 21 - return !dangerousProps.has(keyStr); 22 - } 40 + export type EvaluateOpts = { unwrapSignals?: boolean }; 23 41 24 - function isSafeAccess(object: unknown, key: unknown): boolean { 25 - if (!isSafeProp(key)) { 42 + /** 43 + * Check if a property name is dangerous and should be blocked 44 + */ 45 + function isDangerousProperty(key: unknown): boolean { 46 + if (typeof key !== "string" && typeof key !== "symbol") { 26 47 return false; 27 48 } 49 + return dangerousProps.has(String(key)); 50 + } 28 51 29 - if (typeof object === "function") { 30 - const keyStr = String(key); 31 - if (keyStr === "constructor" && object.name && !safeGlobals.has(object.name)) { 32 - return false; 33 - } 34 - } 35 - 36 - return true; 52 + /** 53 + * Type guard to check if a Dep has a set method (is a Signal vs ComputedSignal) 54 + */ 55 + function hasSetMethod( 56 + dep: unknown, 57 + ): dep is { get: () => unknown; set: (v: unknown) => void; subscribe: (fn: () => void) => () => void } { 58 + return (typeof dep === "object" 59 + && dep !== null 60 + && "set" in dep 61 + && typeof (dep as { set?: unknown }).set === "function"); 37 62 } 38 63 39 - type TokenType = 40 - | "NUMBER" 41 - | "STRING" 42 - | "TRUE" 43 - | "FALSE" 44 - | "NULL" 45 - | "UNDEFINED" 46 - | "IDENTIFIER" 47 - | "DOT" 48 - | "LBRACKET" 49 - | "RBRACKET" 50 - | "LPAREN" 51 - | "RPAREN" 52 - | "LBRACE" 53 - | "RBRACE" 54 - | "COMMA" 55 - | "QUESTION" 56 - | "COLON" 57 - | "ARROW" 58 - | "DOT_DOT_DOT" 59 - | "PLUS" 60 - | "MINUS" 61 - | "STAR" 62 - | "SLASH" 63 - | "PERCENT" 64 - | "BANG" 65 - | "EQ_EQ_EQ" 66 - | "BANG_EQ_EQ" 67 - | "LT" 68 - | "GT" 69 - | "LT_EQ" 70 - | "GT_EQ" 71 - | "AND_AND" 72 - | "OR_OR" 73 - | "EOF"; 64 + /** 65 + * Wrap a signal to behave like its value while preserving methods 66 + * 67 + * Creates a proxy that: 68 + * - Returns signal methods (.get, .subscribe, and .set if available) when accessed 69 + * - Acts like the unwrapped value for all other operations 70 + * - Unwraps nested signals in the value 71 + * 72 + * Handles both Signal (has set) and ComputedSignal (no set) 73 + */ 74 + function wrapSignal( 75 + signal: { get: () => unknown; subscribe: (fn: () => void) => () => void }, 76 + options: WrapOptions, 77 + ): unknown { 78 + const hasSet = hasSetMethod(signal); 74 79 75 - type Token = { type: TokenType; value: unknown; start: number; end: number }; 80 + const wrapper: Record<string | symbol, unknown> = { 81 + get: signal.get, 82 + subscribe: signal.subscribe, 83 + valueOf: () => signal.get(), 84 + toString: () => String(signal.get()), 85 + [Symbol.toPrimitive]: (_hint: string) => signal.get(), 86 + }; 76 87 77 - function tokenize(expr: string): Token[] { 78 - const tokens: Token[] = []; 79 - let pos = 0; 88 + if (hasSet) { 89 + wrapper.set = signal.set; 90 + } 80 91 81 - while (pos < expr.length) { 82 - const char = expr[pos]; 92 + return new Proxy(wrapper, { 93 + get(target, prop) { 94 + if (isDangerousProperty(prop)) { 95 + return; 96 + } 83 97 84 - if (/\s/.test(char)) { 85 - pos++; 86 - continue; 87 - } 98 + if (prop === "get" || prop === "subscribe") { 99 + return target[prop]; 100 + } 88 101 89 - if (/\d/.test(char) || (char === "-" && pos + 1 < expr.length && /\d/.test(expr[pos + 1]))) { 90 - const start = pos; 91 - if (char === "-") pos++; 92 - while (pos < expr.length && /[\d.]/.test(expr[pos])) { 93 - pos++; 102 + if (prop === "set" && hasSet) { 103 + return target[prop]; 94 104 } 95 - tokens.push({ type: "NUMBER", value: Number(expr.slice(start, pos)), start, end: pos }); 96 - continue; 97 - } 98 105 99 - if (char === "\"" || char === "'") { 100 - const start = pos; 101 - const quote = char; 102 - pos++; 103 - let value = ""; 104 - while (pos < expr.length && expr[pos] !== quote) { 105 - if (expr[pos] === "\\") { 106 - pos++; 107 - if (pos < expr.length) { 108 - value += expr[pos]; 109 - } 110 - } else { 111 - value += expr[pos]; 112 - } 113 - pos++; 106 + if (prop === "valueOf" || prop === "toString" || prop === Symbol.toPrimitive) { 107 + return target[prop]; 114 108 } 115 - if (pos < expr.length) pos++; 116 - tokens.push({ type: "STRING", value, start, end: pos }); 117 - continue; 118 - } 119 109 120 - if (/[a-zA-Z_$]/.test(char)) { 121 - const start = pos; 122 - while (pos < expr.length && /[a-zA-Z0-9_$]/.test(expr[pos])) { 123 - pos++; 110 + const unwrapped = signal.get(); 111 + if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) { 112 + const wrapped = wrapValue(unwrapped, options); 113 + return (wrapped as Record<string | symbol, unknown>)[prop]; 124 114 } 125 - const value = expr.slice(start, pos); 126 115 127 - switch (value) { 128 - case "true": { 129 - tokens.push({ type: "TRUE", value: true, start, end: pos }); 130 - break; 116 + if (unwrapped !== null && unwrapped !== undefined) { 117 + const boxed = new Object(unwrapped) as Record<string | symbol, unknown>; 118 + const value = Reflect.get(boxed, prop, boxed); 119 + 120 + if (typeof value === "function") { 121 + return value.bind(unwrapped); 131 122 } 132 - case "false": { 133 - tokens.push({ type: "FALSE", value: false, start, end: pos }); 134 - break; 135 - } 136 - case "null": { 137 - tokens.push({ type: "NULL", value: null, start, end: pos }); 138 - break; 139 - } 140 - case "undefined": { 141 - tokens.push({ type: "UNDEFINED", value: undefined, start, end: pos }); 142 - break; 143 - } 144 - default: { 145 - tokens.push({ type: "IDENTIFIER", value, start, end: pos }); 146 - } 123 + 124 + return wrapValue(value, options); 147 125 } 148 - continue; 149 - } 150 126 151 - const start = pos; 127 + return; 128 + }, 152 129 153 - if (pos + 2 < expr.length) { 154 - const threeChar = expr.slice(pos, pos + 3); 155 - if (threeChar === "===") { 156 - tokens.push({ type: "EQ_EQ_EQ", value: "===", start, end: pos + 3 }); 157 - pos += 3; 158 - continue; 130 + has(_target, prop) { 131 + if (isDangerousProperty(prop)) { 132 + return false; 159 133 } 160 - if (threeChar === "!==") { 161 - tokens.push({ type: "BANG_EQ_EQ", value: "!==", start, end: pos + 3 }); 162 - pos += 3; 163 - continue; 164 - } 165 - if (threeChar === "...") { 166 - tokens.push({ type: "DOT_DOT_DOT", value: "...", start, end: pos + 3 }); 167 - pos += 3; 168 - continue; 134 + 135 + if (prop === "get" || prop === "subscribe") { 136 + return true; 169 137 } 170 - } 171 138 172 - if (pos + 1 < expr.length) { 173 - const twoChar = expr.slice(pos, pos + 2); 174 - switch (twoChar) { 175 - case "<=": { 176 - tokens.push({ type: "LT_EQ", value: "<=", start, end: pos + 2 }); 177 - pos += 2; 178 - continue; 179 - } 180 - case ">=": { 181 - tokens.push({ type: "GT_EQ", value: ">=", start, end: pos + 2 }); 182 - pos += 2; 183 - continue; 184 - } 185 - case "&&": { 186 - tokens.push({ type: "AND_AND", value: "&&", start, end: pos + 2 }); 187 - pos += 2; 188 - continue; 189 - } 190 - case "||": { 191 - tokens.push({ type: "OR_OR", value: "||", start, end: pos + 2 }); 192 - pos += 2; 193 - continue; 194 - } 195 - case "=>": { 196 - tokens.push({ type: "ARROW", value: "=>", start, end: pos + 2 }); 197 - pos += 2; 198 - continue; 199 - } 139 + if (prop === "set" && hasSet) { 140 + return true; 200 141 } 201 - } 202 142 203 - switch (char) { 204 - case ".": { 205 - tokens.push({ type: "DOT", value: ".", start, end: pos + 1 }); 206 - pos++; 207 - break; 143 + const unwrapped = signal.get(); 144 + if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) { 145 + return prop in unwrapped; 208 146 } 209 - case "[": { 210 - tokens.push({ type: "LBRACKET", value: "[", start, end: pos + 1 }); 211 - pos++; 212 - break; 147 + if (unwrapped !== null && unwrapped !== undefined) { 148 + const boxed = new Object(unwrapped) as Record<string | symbol, unknown>; 149 + return Reflect.has(boxed, prop); 213 150 } 214 - case "]": { 215 - tokens.push({ type: "RBRACKET", value: "]", start, end: pos + 1 }); 216 - pos++; 217 - break; 218 - } 219 - case "(": { 220 - tokens.push({ type: "LPAREN", value: "(", start, end: pos + 1 }); 221 - pos++; 222 - break; 223 - } 224 - case ")": { 225 - tokens.push({ type: "RPAREN", value: ")", start, end: pos + 1 }); 226 - pos++; 227 - break; 228 - } 229 - case "+": { 230 - tokens.push({ type: "PLUS", value: "+", start, end: pos + 1 }); 231 - pos++; 232 - break; 233 - } 234 - case "-": { 235 - tokens.push({ type: "MINUS", value: "-", start, end: pos + 1 }); 236 - pos++; 237 - break; 238 - } 239 - case "*": { 240 - tokens.push({ type: "STAR", value: "*", start, end: pos + 1 }); 241 - pos++; 242 - break; 243 - } 244 - case "/": { 245 - tokens.push({ type: "SLASH", value: "/", start, end: pos + 1 }); 246 - pos++; 247 - break; 248 - } 249 - case "%": { 250 - tokens.push({ type: "PERCENT", value: "%", start, end: pos + 1 }); 251 - pos++; 252 - break; 253 - } 254 - case "!": { 255 - tokens.push({ type: "BANG", value: "!", start, end: pos + 1 }); 256 - pos++; 257 - break; 258 - } 259 - case "<": { 260 - tokens.push({ type: "LT", value: "<", start, end: pos + 1 }); 261 - pos++; 262 - break; 263 - } 264 - case ">": { 265 - tokens.push({ type: "GT", value: ">", start, end: pos + 1 }); 266 - pos++; 267 - break; 268 - } 269 - case "{": { 270 - tokens.push({ type: "LBRACE", value: "{", start, end: pos + 1 }); 271 - pos++; 272 - break; 273 - } 274 - case "}": { 275 - tokens.push({ type: "RBRACE", value: "}", start, end: pos + 1 }); 276 - pos++; 277 - break; 278 - } 279 - case ",": { 280 - tokens.push({ type: "COMMA", value: ",", start, end: pos + 1 }); 281 - pos++; 282 - break; 283 - } 284 - case "?": { 285 - tokens.push({ type: "QUESTION", value: "?", start, end: pos + 1 }); 286 - pos++; 287 - break; 288 - } 289 - case ":": { 290 - tokens.push({ type: "COLON", value: ":", start, end: pos + 1 }); 291 - pos++; 292 - break; 293 - } 294 - default: { 295 - throw new Error(`Unexpected character '${char}' at position ${pos}`); 296 - } 297 - } 298 - } 299 - 300 - tokens.push({ type: "EOF", value: null, start: pos, end: pos }); 301 - return tokens; 151 + return false; 152 + }, 153 + }) as unknown; 302 154 } 303 155 304 156 /** 305 - * Recursive descent parser for expression evaluation with operator precedence 157 + * Wrap a value to block dangerous property access 158 + * 159 + * Wraps ALL objects to prevent prototype pollution attacks. 160 + * Built-in methods still work because we only block dangerous properties. 306 161 */ 307 - class Parser { 308 - private tokens: Token[]; 309 - private current = 0; 310 - private scope: Scope; 311 - private dangerousGlobals = new Set(DANGEROUS_GLOBALS); 312 - 313 - constructor(tokens: Token[], scope: Scope) { 314 - this.tokens = tokens; 315 - this.scope = scope; 316 - } 317 - 318 - parse(): unknown { 319 - return this.parseExpr(); 320 - } 321 - 322 - private parseExpr(): unknown { 323 - return this.parseTernary(); 162 + function wrapValue(value: unknown, options: WrapOptions = defaultWrapOptions): unknown { 163 + if (value === null || value === undefined) { 164 + return value; 324 165 } 325 166 326 - private parseTernary(): unknown { 327 - const expr = this.parseLogicalOr(); 328 - 329 - if (this.match("QUESTION")) { 330 - const trueBranch = this.parseExpr(); 331 - this.consume("COLON", "Expected ':' in ternary expression"); 332 - const falseBranch = this.parseExpr(); 333 - return expr ? trueBranch : falseBranch; 167 + if (isSignal(value)) { 168 + if (options.unwrapSignals) { 169 + return wrapValue((value as { get: () => unknown }).get(), options); 334 170 } 335 - 336 - return expr; 171 + return wrapSignal(value, options); 337 172 } 338 173 339 - private parseLogicalOr(): unknown { 340 - let left = this.parseLogicalAnd(); 341 - 342 - while (this.match("OR_OR")) { 343 - const right = this.parseLogicalAnd(); 344 - left = Boolean(left) || Boolean(right); 345 - } 346 - 347 - return left; 174 + if (typeof value !== "object" && typeof value !== "function") { 175 + return value; 348 176 } 349 177 350 - private parseLogicalAnd(): unknown { 351 - let left = this.parseEquality(); 178 + return new Proxy(value as object, { 179 + get(target, prop) { 180 + if (isDangerousProperty(prop)) { 181 + return; 182 + } 352 183 353 - while (this.match("AND_AND")) { 354 - const right = this.parseEquality(); 355 - left = Boolean(left) && Boolean(right); 356 - } 184 + const result = (target as Record<string | symbol, unknown>)[prop]; 357 185 358 - return left; 359 - } 360 - 361 - private parseEquality(): unknown { 362 - let left = this.parseRelational(); 363 - 364 - while (true) { 365 - if (this.match("EQ_EQ_EQ")) { 366 - const right = this.parseRelational(); 367 - left = left === right; 368 - } else if (this.match("BANG_EQ_EQ")) { 369 - const right = this.parseRelational(); 370 - left = left !== right; 371 - } else { 372 - break; 186 + if (typeof result === "function") { 187 + return result.bind(target); 373 188 } 374 - } 375 189 376 - return left; 377 - } 190 + return wrapValue(result, options); 191 + }, 378 192 379 - private parseRelational(): unknown { 380 - let left = this.parseAdditive(); 381 - 382 - while (true) { 383 - if (this.match("LT")) { 384 - const right = this.parseAdditive(); 385 - left = (left as number) < (right as number); 386 - } else if (this.match("GT")) { 387 - const right = this.parseAdditive(); 388 - left = (left as number) > (right as number); 389 - } else if (this.match("LT_EQ")) { 390 - const right = this.parseAdditive(); 391 - left = (left as number) <= (right as number); 392 - } else if (this.match("GT_EQ")) { 393 - const right = this.parseAdditive(); 394 - left = (left as number) >= (right as number); 395 - } else { 396 - break; 193 + set(target, prop, newValue) { 194 + if (isDangerousProperty(prop)) { 195 + return true; 397 196 } 398 - } 399 197 400 - return left; 401 - } 402 - 403 - private parseAdditive(): unknown { 404 - let left = this.parseMultiplicative(); 198 + (target as Record<string | symbol, unknown>)[prop] = newValue; 199 + return true; 200 + }, 405 201 406 - while (true) { 407 - if (this.match("PLUS")) { 408 - const right = this.parseMultiplicative(); 409 - left = (left as number) + (right as number); 410 - } else if (this.match("MINUS")) { 411 - const right = this.parseMultiplicative(); 412 - left = (left as number) - (right as number); 413 - } else { 414 - break; 202 + has(target, prop) { 203 + if (isDangerousProperty(prop)) { 204 + return false; 415 205 } 416 - } 206 + return prop in target; 207 + }, 208 + }); 209 + } 417 210 418 - return left; 419 - } 211 + /** 212 + * Create a hardened proxy around a scope object 213 + * 214 + * This proxy: 215 + * - Blocks access to dangerous properties (constructor, __proto__, prototype, globalThis) 216 + * - Auto-unwraps signals on get (transparent reactivity) 217 + * - Only allows access to scope properties and whitelisted globals 218 + * - Uses Object.create(null) to prevent prototype chain attacks 219 + * - Wraps all returned values to prevent nested dangerous access 220 + * 221 + * @param scope - The scope object to wrap 222 + * @returns Proxied scope with security hardening 223 + */ 224 + function createScopeProxy(scope: Scope, options: WrapOptions = defaultWrapOptions): Scope { 225 + const base = Object.create(null) as Scope; 420 226 421 - private parseMultiplicative(): unknown { 422 - let left = this.parseUnary(); 227 + return new Proxy(base, { 228 + get(_target, prop) { 229 + const propStr = String(prop); 423 230 424 - while (true) { 425 - if (this.match("STAR")) { 426 - const right = this.parseUnary(); 427 - left = (left as number) * (right as number); 428 - } else if (this.match("SLASH")) { 429 - const right = this.parseUnary(); 430 - left = (left as number) / (right as number); 431 - } else if (this.match("PERCENT")) { 432 - const right = this.parseUnary(); 433 - left = (left as number) % (right as number); 434 - } else { 435 - break; 231 + if (dangerousGlobals.has(propStr)) { 232 + return; 436 233 } 437 - } 438 234 439 - return left; 440 - } 235 + if (isDangerousProperty(prop)) { 236 + return; 237 + } 441 238 442 - private parseUnary(): unknown { 443 - if (this.match("BANG")) { 444 - const operand = this.parseUnary(); 445 - return !operand; 446 - } 447 - 448 - if (this.match("MINUS")) { 449 - const operand = this.parseUnary(); 450 - return -(operand as number); 451 - } 452 - 453 - if (this.match("PLUS")) { 454 - const operand = this.parseUnary(); 455 - return +(operand as number); 456 - } 239 + if (propStr in scope) { 240 + const value = scope[propStr]; 241 + return wrapValue(value, options); 242 + } 457 243 458 - return this.parseMemberAccess(); 459 - } 244 + if (safeGlobals.has(propStr)) { 245 + return wrapValue((globalThis as Record<string, unknown>)[propStr], options); 246 + } 460 247 461 - private parseMemberAccess(): unknown { 462 - let object = this.parsePrimary(); 248 + return; 249 + }, 463 250 464 - while (true) { 465 - if (this.match("DOT")) { 466 - const prop = this.consume("IDENTIFIER", "Expected property name after '.'"); 467 - const propValue = this.getMember(object, prop.value as string); 251 + set(_target, prop, value) { 252 + if (isDangerousProperty(prop)) { 253 + return true; 254 + } 468 255 469 - if (this.check("LPAREN")) { 470 - this.advance(); 471 - const args = this.parseArgumentList(); 472 - this.consume("RPAREN", "Expected ')' after arguments"); 473 - const propName = prop.value as string; 474 - const isSignalMethod = isSignal(object) 475 - && (propName === "get" || propName === "set" || propName === "subscribe"); 476 - const unwrappedObject = !isSignalMethod && isSignal(object) ? object.get() : object; 477 - object = this.callMethod(unwrappedObject, propName, args); 478 - } else { 479 - object = propValue; 480 - } 481 - } else if (this.match("LBRACKET")) { 482 - const index = this.parseExpr(); 483 - this.consume("RBRACKET", "Expected ']' after member access"); 484 - object = this.getMember(object, index); 485 - } else if (this.match("LPAREN")) { 486 - const args = this.parseArgumentList(); 487 - this.consume("RPAREN", "Expected ')' after arguments"); 256 + const propStr = String(prop); 488 257 489 - if (typeof object === "function") { 490 - const func = object as { name?: string }; 491 - if (func.name === "Function" || func.name === "eval") { 492 - throw new Error("Cannot call dangerous function"); 493 - } 494 - object = (object as (...args: unknown[]) => unknown)(...args); 495 - } else { 496 - throw new TypeError("Attempting to call a non-function value"); 258 + if (propStr in scope) { 259 + const existing = scope[propStr]; 260 + if (isSignal(existing) && hasSetMethod(existing)) { 261 + existing.set(value); 262 + return true; 497 263 } 498 - } else { 499 - break; 500 264 } 501 - } 502 265 503 - if (isSignal(object)) { 504 - return (object as { get: () => unknown }).get(); 505 - } 266 + scope[propStr] = value; 267 + return true; 268 + }, 506 269 507 - return object; 508 - } 509 - 510 - private parseArgumentList(): unknown[] { 511 - const args: unknown[] = []; 512 - 513 - if (this.check("RPAREN")) { 514 - return args; 515 - } 516 - 517 - do { 518 - args.push(this.parseExpr()); 519 - } while (this.match("COMMA")); 520 - 521 - return args; 522 - } 523 - 524 - private callMethod(object: unknown, methodName: string, args: unknown[]): unknown { 525 - if (isNil(object)) { 526 - throw new Error(`Cannot call method '${methodName}' on ${object}`); 527 - } 528 - 529 - if (!isSafeAccess(object, methodName)) { 530 - throw new Error(`Unsafe method call: ${methodName}`); 531 - } 532 - 533 - const method = (object as Record<string, unknown>)[methodName]; 534 - 535 - if (typeof method !== "function") { 536 - throw new TypeError(`'${methodName}' is not a function`); 537 - } 538 - 539 - return (method as (...args: unknown[]) => unknown).call(object, ...args); 540 - } 541 - 542 - private parsePrimary(): unknown { 543 - if (this.match("NUMBER", "STRING", "TRUE", "FALSE", "NULL", "UNDEFINED")) { 544 - return this.previous().value; 545 - } 270 + /** 271 + * Always return true to prevent 'with' statement from falling back to outer scope 272 + */ 273 + has(_target, prop) { 274 + if (prop === "$unwrap") { 275 + return false; 276 + } 277 + return true; 278 + }, 546 279 547 - if (this.match("IDENTIFIER")) { 548 - const identifier = this.previous().value as string; 280 + ownKeys(_target) { 281 + return Object.keys(scope).filter((key) => !isDangerousProperty(key)); 282 + }, 549 283 550 - if (this.check("ARROW")) { 551 - this.current--; 552 - return this.parseArrowFunction(); 284 + getOwnPropertyDescriptor(_target, prop) { 285 + if (isDangerousProperty(prop)) { 286 + return; 553 287 } 554 288 555 - return this.resolvePropPath(identifier); 556 - } 289 + const propStr = String(prop); 557 290 558 - if (this.match("LPAREN")) { 559 - const start = this.current; 560 - 561 - if (this.isArrowFunctionParams()) { 562 - this.current = start - 1; 563 - return this.parseArrowFunction(); 291 + if (propStr in scope) { 292 + return { configurable: true, enumerable: true, writable: true, value: scope[propStr] }; 564 293 } 565 294 566 - const expr = this.parseExpr(); 567 - this.consume("RPAREN", "Expected ')' after expression"); 568 - return expr; 569 - } 295 + return; 296 + }, 297 + }); 298 + } 570 299 571 - if (this.match("LBRACKET")) { 572 - return this.parseArrayLiteral(); 573 - } 300 + /** 301 + * Cache for compiled expression functions 302 + * 303 + * Key: expression string 304 + * Value: compiled function 305 + */ 306 + type CompiledExpr = (scope: Scope, unwrap: (value: unknown) => unknown) => unknown; 574 307 575 - if (this.match("LBRACE")) { 576 - return this.parseObjectLiteral(); 577 - } 308 + const exprCache = new Map<string, CompiledExpr>(); 578 309 579 - throw new Error(`Unexpected token: ${this.peek().type}`); 310 + function isIdentifierStart(char: string): boolean { 311 + if (char.length === 0) { 312 + return false; 580 313 } 581 - 582 - private parseArrayLiteral(): unknown[] { 583 - const elements: unknown[] = []; 584 - 585 - if (this.match("RBRACKET")) { 586 - return elements; 587 - } 588 - 589 - do { 590 - if (this.match("DOT_DOT_DOT")) { 591 - const spreadValue = this.parseExpr(); 592 - if (Array.isArray(spreadValue)) { 593 - elements.push(...spreadValue); 594 - } else { 595 - throw new TypeError("Spread operator can only be used with arrays"); 596 - } 597 - } else { 598 - elements.push(this.parseExpr()); 599 - } 600 - } while (this.match("COMMA")); 314 + const code = char.charCodeAt(0); 315 + return ((code >= 65 && code <= 90) || (code >= 97 && code <= 122) || char === "_" || char === "$"); 316 + } 601 317 602 - this.consume("RBRACKET", "Expected ']' after array elements"); 603 - return elements; 318 + function isIdentifierPart(char: string): boolean { 319 + if (char.length === 0) { 320 + return false; 604 321 } 322 + const code = char.charCodeAt(0); 323 + return ((code >= 65 && code <= 90) 324 + || (code >= 97 && code <= 122) 325 + || (code >= 48 && code <= 57) 326 + || char === "_" || char === "$"); 327 + } 605 328 606 - private parseObjectLiteral(): Record<string, unknown> { 607 - const object: Record<string, unknown> = {}; 329 + function isWhitespace(char: string): boolean { 330 + return char === " " || char === "\n" || char === "\r" || char === "\t"; 331 + } 608 332 609 - if (this.match("RBRACE")) { 610 - return object; 611 - } 612 - 613 - do { 614 - if (this.match("DOT_DOT_DOT")) { 615 - const spreadValue = this.parseExpr(); 616 - if (typeof spreadValue === "object" && spreadValue !== null && !Array.isArray(spreadValue)) { 617 - for (const key of Object.keys(spreadValue)) { 618 - if (!isSafeProp(key)) { 619 - throw new Error(`Unsafe property in spread: ${key}`); 620 - } 621 - } 622 - Object.assign(object, spreadValue); 623 - } else { 624 - throw new Error("Spread operator can only be used with objects in object literals"); 625 - } 626 - } else { 627 - let key: string; 333 + function transformExpr(expr: string): string { 334 + let result = ""; 335 + let index = 0; 628 336 629 - if (this.match("IDENTIFIER")) { 630 - key = this.previous().value as string; 631 - } else if (this.match("STRING")) { 632 - key = this.previous().value as string; 633 - } else { 634 - throw new Error("Expected property key in object literal"); 635 - } 337 + while (index < expr.length) { 338 + const char = expr[index]; 636 339 637 - if (!isSafeProp(key)) { 638 - throw new Error(`Unsafe property key in object literal: ${key}`); 639 - } 340 + if (char === "!") { 341 + const next = expr[index + 1] ?? ""; 640 342 641 - this.consume("COLON", "Expected ':' after property key"); 642 - const value = this.parseExpr(); 643 - object[key] = value; 343 + if (next === "=") { 344 + result += "!"; 345 + index += 1; 346 + continue; 644 347 } 645 - } while (this.match("COMMA")); 646 348 647 - this.consume("RBRACE", "Expected '}' after object properties"); 648 - return object; 649 - } 650 - 651 - private parseArrowFunction(): (...args: unknown[]) => unknown { 652 - const params: string[] = []; 653 - 654 - if (this.match("IDENTIFIER")) { 655 - params.push(this.previous().value as string); 656 - } else if (this.match("LPAREN")) { 657 - if (!this.check("RPAREN")) { 658 - do { 659 - const param = this.consume("IDENTIFIER", "Expected parameter name"); 660 - params.push(param.value as string); 661 - } while (this.match("COMMA")); 349 + let cursor = index + 1; 350 + while (cursor < expr.length && isWhitespace(expr[cursor])) { 351 + cursor += 1; 662 352 } 663 - this.consume("RPAREN", "Expected ')' after parameters"); 664 - } else { 665 - throw new Error("Expected arrow function parameters"); 666 - } 667 353 668 - this.consume("ARROW", "Expected '=>' in arrow function"); 669 - 670 - if (this.match("LBRACE")) { 671 - let braceDepth = 1; 672 - while (braceDepth > 0 && !this.isAtEnd()) { 673 - if (this.check("LBRACE")) braceDepth++; 674 - if (this.check("RBRACE")) braceDepth--; 675 - this.advance(); 354 + const identStart = expr[cursor] ?? ""; 355 + if (!isIdentifierStart(identStart)) { 356 + result += "!"; 357 + index += 1; 358 + continue; 676 359 } 677 - throw new Error("Arrow function block bodies are not yet supported. Use single expressions only."); 678 - } else { 679 - const exprTokens: Token[] = []; 680 - let parenDepth = 0; 681 - let bracketDepth = 0; 682 - let braceDepth = 0; 683 360 684 - outer: while (!this.isAtEnd()) { 685 - const token = this.peek(); 686 - 687 - switch (token.type) { 688 - case "LPAREN": { 689 - parenDepth++; 690 - break; 691 - } 692 - case "RPAREN": { 693 - if (parenDepth === 0) break outer; 694 - parenDepth--; 695 - break; 696 - } 697 - case "LBRACKET": { 698 - bracketDepth++; 699 - break; 700 - } 701 - case "RBRACKET": { 702 - if (bracketDepth === 0) break outer; 703 - bracketDepth--; 704 - break; 705 - } 706 - case "LBRACE": { 707 - braceDepth++; 708 - break; 709 - } 710 - case "RBRACE": { 711 - if (braceDepth === 0) break outer; 712 - braceDepth--; 713 - break; 714 - } 715 - case "COMMA": { 716 - if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { 717 - break outer; 718 - } 719 - break; 720 - } 721 - default: { 722 - break; 723 - } 724 - } 725 - 726 - exprTokens.push(this.advance()); 361 + let end = cursor + 1; 362 + while (end < expr.length && isIdentifierPart(expr.charAt(end))) { 363 + end += 1; 727 364 } 728 365 729 - const capturedScope = this.scope; 730 - 731 - return (...args: unknown[]) => { 732 - const arrowScope: Scope = { ...capturedScope }; 733 - for (const [index, param] of params.entries()) { 734 - arrowScope[param] = args[index]; 735 - } 736 - 737 - const parser = new Parser([...exprTokens, { type: "EOF", value: null, start: 0, end: 0 }], arrowScope); 738 - return parser.parse(); 739 - }; 740 - } 741 - } 742 - 743 - private isArrowFunctionParams(): boolean { 744 - const saved = this.current; 745 - let result = false; 746 - 747 - try { 748 - if (this.check("RPAREN")) { 749 - this.advance(); 750 - if (this.check("ARROW")) { 751 - result = true; 366 + while (end < expr.length && expr[end] === ".") { 367 + const afterDot = expr[end + 1] ?? ""; 368 + if (!isIdentifierStart(afterDot)) { 369 + break; 752 370 } 753 - } else { 754 - while (!this.isAtEnd() && !this.check("RPAREN")) { 755 - if (!this.match("IDENTIFIER", "COMMA")) { 756 - result = false; 757 - break; 758 - } 759 - } 760 - if (this.match("RPAREN") && this.check("ARROW")) { 761 - result = true; 371 + end += 2; 372 + while (end < expr.length && isIdentifierPart(expr.charAt(end))) { 373 + end += 1; 762 374 } 763 375 } 764 - } finally { 765 - this.current = saved; 766 - } 767 - 768 - return result; 769 - } 770 - 771 - private getMember(object: unknown, key: unknown): unknown { 772 - if (isNil(object)) { 773 - return undefined; 774 - } 775 - 776 - if (!isSafeAccess(object, key)) { 777 - throw new Error(`Unsafe property access: ${String(key)}`); 778 - } 779 376 780 - if (isSignal(object) && (key === "get" || key === "set" || key === "subscribe")) { 781 - return (object as Record<string, unknown>)[key as string]; 782 - } 377 + const nextChar = expr[end] ?? ""; 378 + if (nextChar === "(") { 379 + result += "!"; 380 + index += 1; 381 + continue; 382 + } 783 383 784 - if (isSignal(object)) { 785 - object = (object as { get: () => unknown }).get(); 384 + const identifier = expr.slice(cursor, end); 385 + result += "!$unwrap(" + identifier + ")"; 386 + index = end; 387 + continue; 786 388 } 787 389 788 - const value = (object as Record<string | number, unknown>)[key as string | number]; 789 - 790 - if (isSignal(value)) { 791 - return value.get(); 792 - } 793 - 794 - return value; 390 + result += char; 391 + index += 1; 795 392 } 796 393 797 - private resolvePropPath(path: string): unknown { 798 - if (!isSafeProp(path)) { 799 - throw new Error(`Unsafe property access: ${path}`); 800 - } 801 - 802 - if (this.dangerousGlobals.has(path)) { 803 - throw new Error(`Access to dangerous global: ${path}`); 804 - } 805 - 806 - if (path in this.scope) { 807 - return this.scope[path]; 808 - } 394 + return result; 395 + } 809 396 810 - if (safeGlobals.has(path)) { 811 - return (globalThis as Record<string, unknown>)[path]; 812 - } 813 - 814 - return undefined; 397 + function unwrapMaybeSignal(value: unknown): unknown { 398 + if (isSignal(value)) { 399 + return (value as { get: () => unknown }).get(); 815 400 } 401 + return value; 402 + } 816 403 817 - private match(...types: TokenType[]): boolean { 818 - for (const type of types) { 819 - if (this.check(type)) { 820 - this.advance(); 821 - return true; 822 - } 823 - } 824 - return false; 825 - } 826 - 827 - private check(type: TokenType): boolean { 828 - if (this.isAtEnd()) return false; 829 - return this.peek().type === type; 830 - } 404 + /** 405 + * Compile an expression into a function using the Function constructor 406 + * 407 + * Uses 'with' statement to allow direct variable access from scope. 408 + * The with statement works because we're not in strict mode for the function body, 409 + * but the scope proxy ensures safety. 410 + * 411 + * @param expr - Expression string to compile 412 + * @param isStmt - Whether this is a statement (no return) or expression (return value) 413 + * @returns Compiled function 414 + */ 415 + function compileExpr(expr: string, isStmt = false): CompiledExpr { 416 + const cacheKey = `${isStmt ? "stmt" : "expr"}:${expr}`; 831 417 832 - private advance(): Token { 833 - if (!this.isAtEnd()) this.current++; 834 - return this.previous(); 418 + let fn = exprCache.get(cacheKey); 419 + if (fn) { 420 + return fn; 835 421 } 836 422 837 - private isAtEnd(): boolean { 838 - return this.peek().type === "EOF"; 423 + try { 424 + const transformed = transformExpr(expr); 425 + if (isStmt) { 426 + fn = new Function("$scope", "$unwrap", `with($scope){${transformed}}`) as CompiledExpr; 427 + } else { 428 + fn = new Function("$scope", "$unwrap", `with($scope){return(${transformed})}`) as CompiledExpr; 429 + } 430 + exprCache.set(cacheKey, fn); 431 + return fn; 432 + } catch (error) { 433 + throw new EvaluationError(expr, error); 839 434 } 435 + } 840 436 841 - private peek(): Token { 842 - return this.tokens[this.current]; 437 + /** 438 + * Unwrap signals at the top level only 439 + * 440 + * Unwraps direct signals and wrapped signals but preserves object/array structure. 441 + * This allows bindings to still track nested signals while unwrapping top-level signal results. 442 + */ 443 + function unwrapSignal(value: unknown): unknown { 444 + if (isSignal(value)) { 445 + return (value as { get: () => unknown }).get(); 843 446 } 844 447 845 - private previous(): Token { 846 - return this.tokens[this.current - 1]; 448 + if ( 449 + value 450 + && typeof value === "object" 451 + && typeof (value as { get?: unknown }).get === "function" 452 + && typeof (value as { subscribe?: unknown }).subscribe === "function" 453 + ) { 454 + return (value as { get: () => unknown }).get(); 847 455 } 848 456 849 - private consume(type: TokenType, message: string): Token { 850 - if (this.check(type)) return this.advance(); 851 - throw new Error(`${message} at position ${this.peek().start}`); 852 - } 457 + return value; 853 458 } 854 459 855 460 /** 856 - * Evaluate an expression against a scope object. 461 + * Evaluate an expression against a scope object 857 462 * 858 - * Supports literals, property access, operators, and member access. 463 + * Supports: 464 + * - Literals: numbers, strings, booleans, null, undefined 465 + * - Operators: +, -, *, /, %, ==, !=, ===, !==, <, >, <=, >=, &&, ||, ! 466 + * - Property access: obj.prop, obj['prop'], nested paths 467 + * - Ternary: condition ? trueVal : falseVal 468 + * - Array/object literals: [1, 2, 3], {key: value} 469 + * - Function calls: fn(arg1, arg2) 470 + * - Arrow functions: (x) => x * 2 471 + * - Signals auto-unwrapped 859 472 * 860 473 * @param expr - The expression string to evaluate 861 474 * @param scope - The scope object containing values 862 475 * @returns The evaluated result 476 + * @throws EvaluationError if expression is invalid or evaluation fails 863 477 */ 864 - export function evaluate(expr: string, scope: Scope): unknown { 478 + export function evaluate(expr: string, scope: Scope, opts?: EvaluateOpts): unknown { 865 479 try { 866 - const tokens = tokenize(expr); 867 - const parser = new Parser(tokens, scope); 868 - return parser.parse(); 480 + const fn = compileExpr(expr, false); 481 + const wrapOptions = opts && opts.unwrapSignals ? readWrapOptions : defaultWrapOptions; 482 + const proxiedScope = createScopeProxy(scope, wrapOptions); 483 + const result = fn(proxiedScope, unwrapMaybeSignal); 484 + return unwrapSignal(result); 869 485 } catch (error) { 870 - console.error(`Error evaluating expression "${expr}":`, error); 871 - return undefined; 486 + if (error instanceof EvaluationError) { 487 + throw error; 488 + } 489 + if (error instanceof ReferenceError) { 490 + return undefined; 491 + } 492 + throw new EvaluationError(expr, error); 493 + } 494 + } 495 + 496 + /** 497 + * Evaluate multiple statements against a scope object 498 + * 499 + * Used for event handlers that may contain multiple semicolon-separated statements. 500 + * Statements are executed in order but no return value is captured. 501 + * 502 + * @param expr - The statement(s) to evaluate 503 + * @param scope - The scope object containing values 504 + * @throws EvaluationError if evaluation fails 505 + */ 506 + export function evaluateStatements(expr: string, scope: Scope): void { 507 + try { 508 + const fn = compileExpr(expr, true); 509 + const proxiedScope = createScopeProxy(scope); 510 + fn(proxiedScope, unwrapMaybeSignal); 511 + } catch (error) { 512 + if (error instanceof EvaluationError) { 513 + throw error; 514 + } 515 + throw new EvaluationError(expr, error); 872 516 } 873 517 }
+11 -1
lib/src/core/http.ts
··· 16 16 Scope, 17 17 SwapStrategy, 18 18 } from "$types/volt"; 19 + import { registerDirective } from "./binder"; 19 20 import { evaluate } from "./evaluator"; 20 21 import { sleep } from "./shared"; 21 22 ··· 654 655 655 656 /** 656 657 * Generic HTTP method binding handler 657 - * 658 658 * Attaches an event listener that triggers an HTTP request when fired & automatically serializes forms for POST/PUT/PATCH methods. 659 659 */ 660 660 function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, url: string): void { ··· 688 688 ctx.cleanups.push(cleanup); 689 689 } 690 690 } 691 + 692 + /** 693 + * Auto-register HTTP directives when this module is imported 694 + * This enables tree-shaking: if the HTTP module isn't imported, these directives won't be included in the bundle. 695 + */ 696 + registerDirective("get", bindGet); 697 + registerDirective("post", bindPost); 698 + registerDirective("put", bindPut); 699 + registerDirective("patch", bindPatch); 700 + registerDirective("delete", bindDelete);
+1 -2
lib/src/debug.ts
··· 18 18 * @packageDocumentation 19 19 */ 20 20 21 - import { reactive as coreReactive } from "$core/reactive"; 22 - import { computed as coreComputed, signal as coreSignal } from "$core/signal"; 23 21 import type { ComputedSignal, Signal, SignalType } from "$types/volt"; 24 22 import { 25 23 buildDependencyGraph, ··· 52 50 registerReactive, 53 51 registerSignal, 54 52 } from "./debug/registry"; 53 + import { computed as coreComputed, reactive as coreReactive, signal as coreSignal } from "./index"; 55 54 56 55 /** 57 56 * Create a signal with automatic debug registration.
+3 -1
lib/src/types/volt.d.ts
··· 2 2 3 3 export type Scope = Record<string, unknown>; 4 4 5 + export type EvaluateOpts = { unwrapSignals?: boolean }; 6 + 5 7 /** 6 8 * Context object available to all bindings 7 9 */ ··· 38 40 * Evaluate an expression against the scope. 39 41 * Handles simple property paths, literals, and signal unwrapping. 40 42 */ 41 - evaluate(expression: string): unknown; 43 + evaluate(expression: string, options?: EvaluateOpts): unknown; 42 44 43 45 /** 44 46 * Lifecycle hooks for plugin-specific mount/unmount behavior
+163 -175
lib/test/core/evaluator-security.test.ts
··· 1 - import { evaluate } from "$core/evaluator"; 2 - import { signal } from "$core/signal"; 1 + import { evaluate, evaluateStatements } from "$core/evaluator"; 2 + import type { Scope } from "$types/volt"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 - describe("evaluator security", () => { 6 - describe("prototype pollution prevention", () => { 7 - it("blocks __proto__ property access", () => { 8 - const scope = { obj: {} }; 9 - expect(evaluate("obj.__proto__", scope)).toBe(undefined); 10 - }); 11 - 12 - it("blocks __proto__ assignment attempts", () => { 13 - const scope = { obj: {} }; 14 - const result = evaluate("obj['__proto__']", scope); 5 + describe("Evaluator - Security Tests", () => { 6 + describe("Prototype Pollution Protection", () => { 7 + it("should block __proto__ access", () => { 8 + const scope: Scope = {}; 9 + const result = evaluate("__proto__", scope); 15 10 expect(result).toBe(undefined); 16 11 }); 17 12 18 - it("blocks prototype property access", () => { 19 - const scope = { arr: [] }; 20 - expect(evaluate("arr.prototype", scope)).toBe(undefined); 13 + it("should block __proto__ assignment", () => { 14 + const scope: Scope = {}; 15 + evaluateStatements("__proto__ = {}", scope); 16 + expect(Object.hasOwn(scope, "__proto__")).toBe(false); 21 17 }); 22 18 23 - it("blocks constructor property on objects", () => { 24 - const scope = { obj: {} }; 19 + it("should block constructor access", () => { 20 + const scope: Scope = { obj: {} }; 25 21 expect(evaluate("obj.constructor", scope)).toBe(undefined); 26 22 }); 27 23 28 - it("blocks constructor property on arrays", () => { 29 - const scope = { arr: [1, 2, 3] }; 30 - expect(evaluate("arr.constructor", scope)).toBe(undefined); 24 + it("should block constructor.prototype access", () => { 25 + const scope: Scope = { obj: {} }; 26 + expect(() => evaluate("obj.constructor.prototype", scope)).toThrow(); 31 27 }); 32 28 33 - it("blocks constructor property on strings", () => { 34 - const scope = { text: "hello" }; 35 - expect(evaluate("text.constructor", scope)).toBe(undefined); 29 + it("should block prototype property access", () => { 30 + const scope: Scope = { fn: () => {} }; 31 + expect(evaluate("fn.prototype", scope)).toBe(undefined); 36 32 }); 33 + }); 37 34 38 - it("blocks nested constructor access", () => { 39 - const scope = { obj: { nested: {} } }; 40 - expect(evaluate("obj.nested.constructor", scope)).toBe(undefined); 35 + describe("Dangerous Global Blocking", () => { 36 + it("should block Function constructor access", () => { 37 + const scope: Scope = {}; 38 + const result = evaluate("Function", scope); 39 + expect(result).toBe(undefined); 41 40 }); 42 41 43 - it("blocks bracket notation __proto__ access", () => { 44 - const scope = { obj: {}, key: "__proto__" }; 45 - expect(evaluate("obj[key]", scope)).toBe(undefined); 42 + it("should block eval access", () => { 43 + const scope: Scope = {}; 44 + const result = evaluate("eval", scope); 45 + expect(result).toBe(undefined); 46 46 }); 47 47 48 - it("blocks bracket notation constructor access", () => { 49 - const scope = { obj: {}, key: "constructor" }; 50 - expect(evaluate("obj[key]", scope)).toBe(undefined); 48 + it("should block globalThis access", () => { 49 + const scope: Scope = {}; 50 + const result = evaluate("globalThis", scope); 51 + expect(result).toBe(undefined); 51 52 }); 52 53 53 - it("blocks bracket notation prototype access", () => { 54 - const scope = { obj: {}, key: "prototype" }; 55 - expect(evaluate("obj[key]", scope)).toBe(undefined); 54 + it("should block window access", () => { 55 + const scope: Scope = {}; 56 + expect(evaluate("window", scope)).toBe(undefined); 56 57 }); 57 - }); 58 58 59 - describe("sandbox escape prevention", () => { 60 - it("blocks direct constructor access from scope", () => { 61 - const scope = { constructor: function() {} }; 62 - expect(evaluate("constructor", scope)).toBe(undefined); 59 + it("should block self access", () => { 60 + const scope: Scope = {}; 61 + expect(evaluate("self", scope)).toBe(undefined); 63 62 }); 64 63 65 - it("blocks direct __proto__ access from scope", () => { 66 - const scope = { __proto__: {} }; 67 - expect(evaluate("__proto__", scope)).toBe(undefined); 68 - }); 69 - 70 - it("blocks direct prototype access from scope", () => { 71 - const scope = { prototype: {} }; 72 - expect(evaluate("prototype", scope)).toBe(undefined); 73 - }); 74 - 75 - it("prevents Function constructor access via constructor.constructor", () => { 76 - const scope = { fn: () => {} }; 77 - expect(evaluate("fn.constructor", scope)).toBe(undefined); 78 - }); 79 - 80 - it("prevents calling dangerous global functions", () => { 81 - const scope = { Function: globalThis.Function, eval: globalThis.eval }; 82 - expect(evaluate("Function", scope)).toBe(undefined); 83 - expect(evaluate("eval", scope)).toBe(undefined); 64 + it("should block import access", () => { 65 + const scope: Scope = {}; 66 + expect(() => evaluate("import", scope)).toThrow(); 84 67 }); 85 68 }); 86 69 87 - describe("method call security", () => { 88 - it("blocks constructor method calls", () => { 89 - const scope = { obj: {} }; 90 - expect(evaluate("obj.constructor()", scope)).toBe(undefined); 70 + describe("Constructor Escape Protection", () => { 71 + it("should prevent constructor escape via array", () => { 72 + const scope: Scope = { arr: [] }; 73 + expect(evaluate("arr.constructor", scope)).toBe(undefined); 91 74 }); 92 75 93 - it("blocks __proto__ method calls", () => { 94 - const scope = { obj: {} }; 95 - expect(evaluate("obj.__proto__()", scope)).toBe(undefined); 76 + it("should prevent constructor escape via object", () => { 77 + const scope: Scope = { obj: {} }; 78 + expect(evaluate("obj.constructor", scope)).toBe(undefined); 96 79 }); 97 80 98 - it("allows safe method calls", () => { 99 - const scope = { text: "hello" }; 100 - expect(evaluate("text.toUpperCase()", scope)).toBe("HELLO"); 101 - expect(evaluate("text.substring(0, 3)", scope)).toBe("hel"); 81 + it("should prevent constructor escape via function", () => { 82 + const scope: Scope = { fn: () => {} }; 83 + expect(evaluate("fn.constructor", scope)).toBe(undefined); 102 84 }); 103 85 104 - it("allows safe array method calls", () => { 105 - const scope = { items: [1, 2, 3] }; 106 - expect(evaluate("items.slice(1)", scope)).toEqual([2, 3]); 107 - expect(evaluate("items.map(x => x * 2)", scope)).toEqual([2, 4, 6]); 86 + it("should prevent prototype chain traversal", () => { 87 + const scope: Scope = { obj: {} }; 88 + expect(evaluate("obj.__proto__", scope)).toBe(undefined); 108 89 }); 109 90 }); 110 91 111 - describe("object literal security", () => { 112 - it("allows creating safe object literals", () => { 113 - expect(evaluate("{ name: 'test', value: 42 }", {})).toEqual({ name: "test", value: 42 }); 92 + describe("Safe Global Access", () => { 93 + it("should allow Math access", () => { 94 + const scope: Scope = {}; 95 + expect(evaluate("Math.PI", scope)).toBe(Math.PI); 96 + expect(evaluate("Math.max(10, 20)", scope)).toBe(20); 114 97 }); 115 98 116 - it("blocks dangerous keys in object literals", () => { 117 - expect(evaluate("{ __proto__: { polluted: true } }", {})).toBe(undefined); 99 + it("should allow Date access", () => { 100 + const scope: Scope = {}; 101 + const result = evaluate("Date.now()", scope); 102 + expect(typeof result).toBe("number"); 118 103 }); 119 104 120 - it("blocks constructor key in object literals", () => { 121 - expect(evaluate("{ constructor: 'bad' }", {})).toBe(undefined); 105 + it("should allow String access", () => { 106 + const scope: Scope = {}; 107 + expect(evaluate("String(123)", scope)).toBe("123"); 122 108 }); 123 109 124 - it("blocks prototype key in object literals", () => { 125 - expect(evaluate("{ prototype: 'bad' }", {})).toBe(undefined); 110 + it("should allow Number access", () => { 111 + const scope: Scope = {}; 112 + expect(evaluate("Number('42')", scope)).toBe(42); 126 113 }); 127 - }); 128 114 129 - describe("array literal security", () => { 130 - it("allows creating safe array literals", () => { 131 - expect(evaluate("[1, 2, 3]", {})).toEqual([1, 2, 3]); 115 + it("should allow Boolean access", () => { 116 + const scope: Scope = {}; 117 + expect(evaluate("Boolean(1)", scope)).toBe(true); 132 118 }); 133 119 134 - it("allows spreading safe arrays", () => { 135 - const scope = { items: [1, 2, 3] }; 136 - expect(evaluate("[0, ...items, 4]", scope)).toEqual([0, 1, 2, 3, 4]); 120 + it("should allow Array access", () => { 121 + const scope: Scope = {}; 122 + const result = evaluate("Array.isArray([])", scope); 123 + expect(result).toBe(true); 137 124 }); 138 - }); 139 125 140 - describe("arrow function security", () => { 141 - it("allows safe arrow functions", () => { 142 - const scope = { items: [1, 2, 3] }; 143 - expect(evaluate("items.map(x => x * 2)", scope)).toEqual([2, 4, 6]); 126 + it("should allow Object access for safe methods", () => { 127 + const scope: Scope = {}; 128 + const result = evaluate("Object.keys({ a: 1, b: 2 })", scope); 129 + expect(result).toEqual(["a", "b"]); 144 130 }); 145 131 146 - it("blocks dangerous property access in arrow functions", () => { 147 - const scope = { items: [{}] }; 148 - expect(evaluate("items.map(x => x.__proto__)", scope)).toBe(undefined); 132 + it("should allow JSON access", () => { 133 + const scope: Scope = {}; 134 + const result = evaluate("JSON.parse('{\"key\":\"value\"}')", scope); 135 + expect(result).toEqual({ key: "value" }); 149 136 }); 150 137 151 - it("blocks constructor access in arrow functions", () => { 152 - const scope = { items: [{}] }; 153 - expect(evaluate("items.map(x => x.constructor)", scope)).toBe(undefined); 138 + it("should allow console access", () => { 139 + const scope: Scope = {}; 140 + expect(() => evaluate("console", scope)).not.toThrow(); 154 141 }); 155 142 }); 156 143 157 - describe("signal security", () => { 158 - it("allows accessing signal values safely", () => { 159 - const scope = { count: signal(5) }; 160 - expect(evaluate("count", scope)).toBe(5); 144 + describe("Scope Isolation", () => { 145 + it("should not leak variables between scopes", () => { 146 + const scope1: Scope = { secret: "value1" }; 147 + const scope2: Scope = { secret: "value2" }; 148 + 149 + expect(evaluate("secret", scope1)).toBe("value1"); 150 + expect(evaluate("secret", scope2)).toBe("value2"); 161 151 }); 162 152 163 - it("allows calling signal methods", () => { 164 - const count = signal(5); 165 - const scope = { count }; 166 - expect(evaluate("count.get()", scope)).toBe(5); 153 + it("should return undefined for missing variables", () => { 154 + const scope: Scope = {}; 155 + expect(evaluate("missing", scope)).toBe(undefined); 167 156 }); 168 157 169 - it("blocks dangerous property access on signals", () => { 170 - const count = signal(5); 171 - const scope = { count }; 172 - expect(evaluate("count.constructor", scope)).toBe(undefined); 158 + it("should not allow access to scope object internals", () => { 159 + const scope: Scope = { value: 42 }; 160 + const result = evaluate("constructor", scope); 161 + expect(result).toBe(undefined); 173 162 }); 174 163 }); 175 164 176 - describe("nested security", () => { 177 - it("blocks nested __proto__ access chains", () => { 178 - const scope = { a: { b: { c: {} } } }; 179 - expect(evaluate("a.b.c.__proto__", scope)).toBe(undefined); 165 + describe("Safe Property Names", () => { 166 + it("should allow normal property names", () => { 167 + const scope: Scope = { user: { name: "Alice", age: 30 } }; 168 + expect(evaluate("user.name", scope)).toBe("Alice"); 169 + expect(evaluate("user.age", scope)).toBe(30); 180 170 }); 181 171 182 - it("blocks mixed access patterns", () => { 183 - const scope = { obj: { arr: [{}] } }; 184 - expect(evaluate("obj.arr[0].__proto__", scope)).toBe(undefined); 185 - expect(evaluate("obj.arr[0].constructor", scope)).toBe(undefined); 172 + it("should allow underscore-prefixed properties", () => { 173 + const scope: Scope = { _private: "value" }; 174 + expect(evaluate("_private", scope)).toBe("value"); 186 175 }); 187 176 188 - it("prevents prototype pollution via spread (enumerable properties only)", () => { 189 - const scope = { malicious: { constructor: "bad", normalProp: "ok" } }; 190 - expect(evaluate("{ ...malicious }", scope)).toBe(undefined); 177 + it("should allow dollar-prefixed properties", () => { 178 + const scope: Scope = { $special: "value" }; 179 + expect(evaluate("$special", scope)).toBe("value"); 191 180 }); 192 - }); 193 181 194 - describe("real-world attack scenarios", () => { 195 - it("prevents prototype pollution via object assignment", () => { 196 - const scope = { target: {}, key: "__proto__", value: { polluted: true } }; 197 - expect(evaluate("target[key]", scope)).toBe(undefined); 182 + it("should allow numeric property access", () => { 183 + const scope: Scope = { items: ["a", "b", "c"] }; 184 + expect(evaluate("items[0]", scope)).toBe("a"); 185 + expect(evaluate("items[1]", scope)).toBe("b"); 198 186 }); 187 + }); 199 188 200 - it("prevents constructor.constructor access pattern", () => { 201 - const scope = { fn: () => {} }; 202 - expect(evaluate("fn.constructor.constructor", scope)).toBe(undefined); 189 + describe("Attack Vector Prevention", () => { 190 + it("should prevent prototype pollution via __proto__", () => { 191 + const scope: Scope = { obj: {} }; 192 + expect(() => evaluateStatements("obj.__proto__.polluted = true", scope)).toThrow(); 193 + expect((Object.prototype as Record<string, unknown>).polluted).toBe(undefined); 203 194 }); 204 195 205 - it("prevents accessing Function by name even if in scope", () => { 206 - const scope = { Function: globalThis.Function, eval: globalThis.eval }; 207 - 208 - expect(evaluate("Function", scope)).toBe(undefined); 209 - expect(evaluate("eval", scope)).toBe(undefined); 196 + it("should prevent prototype pollution via constructor.prototype", () => { 197 + const scope: Scope = { obj: {} }; 198 + expect(() => evaluateStatements("obj.constructor.prototype.polluted = true", scope)).toThrow(); 199 + expect((Object.prototype as Record<string, unknown>).polluted).toBe(undefined); 210 200 }); 211 201 212 - it("prevents eval through constructor chain", () => { 213 - const scope = { arr: [] }; 214 - expect(evaluate("arr.constructor.constructor", scope)).toBe(undefined); 215 - }); 216 - }); 217 - 218 - describe("legitimate use cases still work", () => { 219 - it("allows normal property access", () => { 220 - const scope = { user: { name: "Alice", age: 30 } }; 221 - expect(evaluate("user.name", scope)).toBe("Alice"); 222 - expect(evaluate("user.age", scope)).toBe(30); 202 + it("should prevent code injection via Function constructor", () => { 203 + const scope: Scope = { code: "alert('xss')" }; 204 + expect(() => evaluate("Function(code)", scope)).toThrow(/not a function/); 223 205 }); 224 206 225 - it("allows array operations", () => { 226 - const scope = { items: [1, 2, 3, 4, 5] }; 227 - expect(evaluate("items.length", scope)).toBe(5); 228 - expect(evaluate("items[0]", scope)).toBe(1); 229 - expect(evaluate("items.slice(1, 3)", scope)).toEqual([2, 3]); 207 + it("should prevent code injection via eval", () => { 208 + const scope: Scope = { code: "console.log('test')" }; 209 + expect(() => evaluate("eval(code)", scope)).toThrow(/not a function/); 230 210 }); 231 211 232 - it("allows string operations", () => { 233 - const scope = { text: "hello world" }; 234 - expect(evaluate("text.length", scope)).toBe(11); 235 - expect(evaluate("text.toUpperCase()", scope)).toBe("HELLO WORLD"); 236 - expect(evaluate("text.substring(0, 5)", scope)).toBe("hello"); 212 + it("should prevent indirect eval via globalThis", () => { 213 + const scope: Scope = { code: "console.log('test')" }; 214 + expect(() => evaluate("globalThis.eval(code)", scope)).toThrow(); 237 215 }); 216 + }); 238 217 239 - it("allows object creation", () => { 240 - expect(evaluate("{ active: true, count: 5 }", {})).toEqual({ active: true, count: 5 }); 218 + describe("Edge Cases", () => { 219 + it("should handle null values safely", () => { 220 + const scope: Scope = { value: null }; 221 + expect(evaluate("value", scope)).toBe(null); 241 222 }); 242 223 243 - it("allows array creation", () => { 244 - expect(evaluate("[1, 2, 3]", {})).toEqual([1, 2, 3]); 224 + it("should handle undefined values safely", () => { 225 + const scope: Scope = { value: undefined }; 226 + expect(evaluate("value", scope)).toBe(undefined); 245 227 }); 246 228 247 - it("allows function composition", () => { 248 - const scope = { items: [1, 2, 3, 4, 5] }; 249 - expect(evaluate("items.filter(x => x > 2).map(x => x * 2)", scope)).toEqual([6, 8, 10]); 229 + it("should handle empty strings safely", () => { 230 + const scope: Scope = { value: "" }; 231 + expect(evaluate("value", scope)).toBe(""); 250 232 }); 251 233 252 - it("allows complex expressions", () => { 253 - const scope = { count: 5, limit: 10, items: [1, 2, 3] }; 254 - expect(evaluate("count < limit && items.length > 0", scope)).toBe(true); 234 + it("should handle symbol keys safely", () => { 235 + const scope: Scope = {}; 236 + const sym = Symbol("test"); 237 + scope[sym as unknown as string] = "value"; 238 + expect(evaluate("test", scope)).toBe(undefined); 255 239 }); 240 + }); 256 241 257 - it("allows ternary with safe operations", () => { 258 - const scope = { count: 5 }; 259 - expect(evaluate("count > 0 ? 'positive' : 'zero'", scope)).toBe("positive"); 242 + describe("Object.create(null) Protection", () => { 243 + it("should work with null-prototype objects", () => { 244 + const scope: Scope = { nullProto: Object.create(null) }; 245 + // @ts-expect-error cast from unknown 246 + scope.nullProto.value = 42; 247 + expect(evaluate("nullProto.value", scope)).toBe(42); 260 248 }); 261 249 262 - it("allows signals in complex expressions", () => { 263 - const scope = { count: signal(5), limit: 10 }; 264 - expect(evaluate("count * 2 > limit", scope)).toBe(false); 265 - expect(evaluate("count * 3 > limit", scope)).toBe(true); 250 + it("should prevent attacks on null-prototype objects", () => { 251 + const scope: Scope = { nullProto: Object.create(null) }; 252 + // constructor property is blocked, returns undefined 253 + expect(evaluate("nullProto.constructor", scope)).toBe(undefined); 266 254 }); 267 255 }); 268 256 });
+230 -450
lib/test/core/evaluator.test.ts
··· 1 - import { evaluate } from "$core/evaluator"; 1 + import { evaluate, evaluateStatements, EvaluationError } from "$core/evaluator"; 2 2 import { signal } from "$core/signal"; 3 - import { describe, expect, it } from "vitest"; 4 - 5 - describe("evaluator", () => { 6 - describe("literals", () => { 7 - it("evaluates boolean literals", () => { 8 - expect(evaluate("true", {})).toBe(true); 9 - expect(evaluate("false", {})).toBe(false); 10 - }); 3 + import type { Scope, Signal } from "$types/volt"; 4 + import { beforeEach, describe, expect, it } from "vitest"; 11 5 12 - it("evaluates null and undefined", () => { 13 - expect(evaluate("null", {})).toBe(null); 14 - expect(evaluate("undefined", {})).toBe(undefined); 15 - }); 6 + describe("Evaluator - Functional Tests", () => { 7 + let scope: Scope; 16 8 17 - it("evaluates number literals", () => { 18 - expect(evaluate("42", {})).toBe(42); 19 - expect(evaluate("3.14", {})).toBe(3.14); 20 - expect(evaluate("0", {})).toBe(0); 21 - expect(evaluate("-5", {})).toBe(-5); 22 - expect(evaluate("-3.5", {})).toBe(-3.5); 23 - }); 24 - 25 - it("evaluates string literals", () => { 26 - expect(evaluate("'hello'", {})).toBe("hello"); 27 - expect(evaluate("\"world\"", {})).toBe("world"); 28 - expect(evaluate("'multi word string'", {})).toBe("multi word string"); 29 - expect(evaluate("''", {})).toBe(""); 30 - }); 31 - 32 - it("handles escaped characters in strings", () => { 33 - expect(evaluate(String.raw`'it\'s'`, {})).toBe("it's"); 34 - expect(evaluate(String.raw`"say \"hi\""`, {})).toBe("say \"hi\""); 35 - }); 9 + beforeEach(() => { 10 + scope = {}; 36 11 }); 37 12 38 - describe("property access", () => { 39 - it("resolves simple identifiers", () => { 40 - const scope = { count: 5, name: "Alice" }; 41 - expect(evaluate("count", scope)).toBe(5); 42 - expect(evaluate("name", scope)).toBe("Alice"); 13 + describe("Literals", () => { 14 + it("should evaluate number literals", () => { 15 + expect(evaluate("42", scope)).toBe(42); 16 + expect(evaluate("-10", scope)).toBe(-10); 17 + expect(evaluate("3.14", scope)).toBe(3.14); 43 18 }); 44 19 45 - it("resolves nested property paths with dot notation", () => { 46 - const scope = { user: { name: "Bob", age: 30 } }; 47 - expect(evaluate("user.name", scope)).toBe("Bob"); 48 - expect(evaluate("user.age", scope)).toBe(30); 49 - }); 50 - 51 - it("resolves array elements with bracket notation", () => { 52 - const scope = { items: ["first", "second", "third"], index: 1 }; 53 - expect(evaluate("items[0]", scope)).toBe("first"); 54 - expect(evaluate("items[1]", scope)).toBe("second"); 55 - expect(evaluate("items[index]", scope)).toBe("second"); 56 - }); 57 - 58 - it("handles mixed dot and bracket notation", () => { 59 - const scope = { data: { users: [{ name: "Alice" }, { name: "Bob" }] } }; 60 - expect(evaluate("data.users[0].name", scope)).toBe("Alice"); 61 - expect(evaluate("data.users[1].name", scope)).toBe("Bob"); 20 + it("should evaluate string literals", () => { 21 + expect(evaluate("'hello'", scope)).toBe("hello"); 22 + expect(evaluate("\"world\"", scope)).toBe("world"); 23 + expect(evaluate("\"hello world\"", scope)).toBe("hello world"); 62 24 }); 63 25 64 - it("returns undefined for missing properties", () => { 65 - const scope = { exists: 42 }; 66 - expect(evaluate("missing", scope)).toBe(undefined); 67 - expect(evaluate("exists.nested", scope)).toBe(undefined); 26 + it("should evaluate boolean literals", () => { 27 + expect(evaluate("true", scope)).toBe(true); 28 + expect(evaluate("false", scope)).toBe(false); 68 29 }); 69 30 70 - it("auto-unwraps signals", () => { 71 - const scope = { count: signal(10), user: { age: signal(25) } }; 72 - expect(evaluate("count", scope)).toBe(10); 73 - expect(evaluate("user.age", scope)).toBe(25); 31 + it("should evaluate null and undefined", () => { 32 + expect(evaluate("null", scope)).toBe(null); 33 + expect(evaluate("undefined", scope)).toBe(undefined); 74 34 }); 75 35 }); 76 36 77 - describe("arithmetic operators", () => { 78 - it("evaluates addition", () => { 79 - expect(evaluate("5 + 3", {})).toBe(8); 80 - expect(evaluate("10 + 20 + 30", {})).toBe(60); 37 + describe("Arithmetic Operators", () => { 38 + it("should handle addition", () => { 39 + expect(evaluate("1 + 2", scope)).toBe(3); 40 + expect(evaluate("10 + 5", scope)).toBe(15); 81 41 }); 82 42 83 - it("evaluates subtraction", () => { 84 - expect(evaluate("10 - 3", {})).toBe(7); 85 - expect(evaluate("100 - 20 - 5", {})).toBe(75); 43 + it("should handle subtraction", () => { 44 + expect(evaluate("10 - 5", scope)).toBe(5); 45 + expect(evaluate("5 - 10", scope)).toBe(-5); 86 46 }); 87 47 88 - it("evaluates multiplication", () => { 89 - expect(evaluate("5 * 3", {})).toBe(15); 90 - expect(evaluate("2 * 3 * 4", {})).toBe(24); 48 + it("should handle multiplication", () => { 49 + expect(evaluate("3 * 4", scope)).toBe(12); 50 + expect(evaluate("10 * 0", scope)).toBe(0); 91 51 }); 92 52 93 - it("evaluates division", () => { 94 - expect(evaluate("10 / 2", {})).toBe(5); 95 - expect(evaluate("100 / 4 / 5", {})).toBe(5); 53 + it("should handle division", () => { 54 + expect(evaluate("10 / 2", scope)).toBe(5); 55 + expect(evaluate("7 / 2", scope)).toBe(3.5); 96 56 }); 97 57 98 - it("evaluates modulo", () => { 99 - expect(evaluate("10 % 3", {})).toBe(1); 100 - expect(evaluate("7 % 2", {})).toBe(1); 101 - expect(evaluate("8 % 4", {})).toBe(0); 58 + it("should handle modulo", () => { 59 + expect(evaluate("10 % 3", scope)).toBe(1); 60 + expect(evaluate("7 % 2", scope)).toBe(1); 102 61 }); 103 62 104 - it("respects operator precedence", () => { 105 - expect(evaluate("2 + 3 * 4", {})).toBe(14); 106 - expect(evaluate("10 - 2 * 3", {})).toBe(4); 107 - expect(evaluate("20 / 4 + 3", {})).toBe(8); 108 - expect(evaluate("10 % 3 + 2", {})).toBe(3); 109 - }); 110 - 111 - it("evaluates with variables", () => { 112 - const scope = { a: 5, b: 3, c: signal(2) }; 113 - expect(evaluate("a + b", scope)).toBe(8); 114 - expect(evaluate("a * b", scope)).toBe(15); 115 - expect(evaluate("a + c", scope)).toBe(7); 116 - expect(evaluate("a * b + c", scope)).toBe(17); 63 + it("should respect operator precedence", () => { 64 + expect(evaluate("2 + 3 * 4", scope)).toBe(14); 65 + expect(evaluate("(2 + 3) * 4", scope)).toBe(20); 117 66 }); 118 67 }); 119 68 120 - describe("comparison operators", () => { 121 - it("evaluates strict equality", () => { 122 - expect(evaluate("5 === 5", {})).toBe(true); 123 - expect(evaluate("5 === 3", {})).toBe(false); 124 - expect(evaluate("'hello' === 'hello'", {})).toBe(true); 125 - expect(evaluate("'hello' === 'world'", {})).toBe(false); 126 - expect(evaluate("true === true", {})).toBe(true); 127 - expect(evaluate("true === false", {})).toBe(false); 128 - }); 129 - 130 - it("evaluates strict inequality", () => { 131 - expect(evaluate("5 !== 3", {})).toBe(true); 132 - expect(evaluate("5 !== 5", {})).toBe(false); 133 - expect(evaluate("'hello' !== 'world'", {})).toBe(true); 134 - }); 135 - 136 - it("evaluates less than", () => { 137 - expect(evaluate("3 < 5", {})).toBe(true); 138 - expect(evaluate("5 < 3", {})).toBe(false); 139 - expect(evaluate("5 < 5", {})).toBe(false); 140 - }); 141 - 142 - it("evaluates greater than", () => { 143 - expect(evaluate("5 > 3", {})).toBe(true); 144 - expect(evaluate("3 > 5", {})).toBe(false); 145 - expect(evaluate("5 > 5", {})).toBe(false); 146 - }); 147 - 148 - it("evaluates less than or equal", () => { 149 - expect(evaluate("3 <= 5", {})).toBe(true); 150 - expect(evaluate("5 <= 5", {})).toBe(true); 151 - expect(evaluate("7 <= 5", {})).toBe(false); 152 - }); 153 - 154 - it("evaluates greater than or equal", () => { 155 - expect(evaluate("5 >= 3", {})).toBe(true); 156 - expect(evaluate("5 >= 5", {})).toBe(true); 157 - expect(evaluate("3 >= 5", {})).toBe(false); 69 + describe("Comparison Operators", () => { 70 + it("should handle equality", () => { 71 + expect(evaluate("5 === 5", scope)).toBe(true); 72 + expect(evaluate("5 === 6", scope)).toBe(false); 73 + expect(evaluate("5 !== 6", scope)).toBe(true); 74 + expect(evaluate("5 !== 5", scope)).toBe(false); 158 75 }); 159 76 160 - it("compares variables", () => { 161 - const scope = { count: 10, limit: 5, target: signal(10) }; 162 - expect(evaluate("count > limit", scope)).toBe(true); 163 - expect(evaluate("count === target", scope)).toBe(true); 164 - expect(evaluate("limit < count", scope)).toBe(true); 77 + it("should handle relational operators", () => { 78 + expect(evaluate("5 < 10", scope)).toBe(true); 79 + expect(evaluate("10 < 5", scope)).toBe(false); 80 + expect(evaluate("5 > 3", scope)).toBe(true); 81 + expect(evaluate("3 > 5", scope)).toBe(false); 82 + expect(evaluate("5 <= 5", scope)).toBe(true); 83 + expect(evaluate("5 >= 5", scope)).toBe(true); 165 84 }); 166 85 }); 167 86 168 - describe("logical operators", () => { 169 - it("evaluates logical AND", () => { 170 - expect(evaluate("true && true", {})).toBe(true); 171 - expect(evaluate("true && false", {})).toBe(false); 172 - expect(evaluate("false && true", {})).toBe(false); 173 - expect(evaluate("false && false", {})).toBe(false); 87 + describe("Logical Operators", () => { 88 + it("should handle AND operator", () => { 89 + expect(evaluate("true && true", scope)).toBe(true); 90 + expect(evaluate("true && false", scope)).toBe(false); 91 + expect(evaluate("false && true", scope)).toBe(false); 174 92 }); 175 93 176 - it("evaluates logical OR", () => { 177 - expect(evaluate("true || true", {})).toBe(true); 178 - expect(evaluate("true || false", {})).toBe(true); 179 - expect(evaluate("false || true", {})).toBe(true); 180 - expect(evaluate("false || false", {})).toBe(false); 94 + it("should handle OR operator", () => { 95 + expect(evaluate("true || false", scope)).toBe(true); 96 + expect(evaluate("false || true", scope)).toBe(true); 97 + expect(evaluate("false || false", scope)).toBe(false); 181 98 }); 182 99 183 - it("evaluates logical NOT", () => { 184 - expect(evaluate("!true", {})).toBe(false); 185 - expect(evaluate("!false", {})).toBe(true); 186 - expect(evaluate("!!true", {})).toBe(true); 187 - }); 188 - 189 - it("combines logical operators", () => { 190 - expect(evaluate("true && true || false", {})).toBe(true); 191 - expect(evaluate("false || true && true", {})).toBe(true); 192 - expect(evaluate("!false && true", {})).toBe(true); 193 - }); 194 - 195 - it("evaluates with truthy/falsy values", () => { 196 - const scope = { zero: 0, one: 1, empty: "", text: "hello", nil: null }; 197 - expect(evaluate("zero && one", scope)).toBe(false); 198 - expect(evaluate("one && text", scope)).toBe(true); 199 - expect(evaluate("empty || text", scope)).toBe(true); 200 - expect(evaluate("!zero", scope)).toBe(true); 201 - expect(evaluate("!one", scope)).toBe(false); 100 + it("should handle NOT operator", () => { 101 + expect(evaluate("!true", scope)).toBe(false); 102 + expect(evaluate("!false", scope)).toBe(true); 103 + expect(evaluate("!!true", scope)).toBe(true); 202 104 }); 203 105 }); 204 106 205 - describe("unary operators", () => { 206 - it("evaluates unary minus", () => { 207 - expect(evaluate("-5", {})).toBe(-5); 208 - expect(evaluate("-(-5)", {})).toBe(5); 209 - expect(evaluate("-(3 + 2)", {})).toBe(-5); 107 + describe("Ternary Operator", () => { 108 + it("should evaluate ternary expressions", () => { 109 + expect(evaluate("true ? 'yes' : 'no'", scope)).toBe("yes"); 110 + expect(evaluate("false ? 'yes' : 'no'", scope)).toBe("no"); 111 + expect(evaluate("5 > 3 ? 'greater' : 'lesser'", scope)).toBe("greater"); 210 112 }); 211 113 212 - it("evaluates unary plus", () => { 213 - expect(evaluate("+5", {})).toBe(5); 214 - expect(evaluate("+(-5)", {})).toBe(-5); 215 - }); 216 - 217 - it("evaluates unary NOT", () => { 218 - expect(evaluate("!true", {})).toBe(false); 219 - expect(evaluate("!false", {})).toBe(true); 220 - expect(evaluate("!0", {})).toBe(true); 221 - expect(evaluate("!1", {})).toBe(false); 114 + it("should handle nested ternaries", () => { 115 + expect(evaluate("true ? (false ? 'a' : 'b') : 'c'", scope)).toBe("b"); 222 116 }); 223 117 }); 224 118 225 - describe("grouped expressions", () => { 226 - it("evaluates parenthesized expressions", () => { 227 - expect(evaluate("(5 + 3) * 2", {})).toBe(16); 228 - expect(evaluate("10 / (2 + 3)", {})).toBe(2); 229 - expect(evaluate("(10 - 5) * (3 + 2)", {})).toBe(25); 119 + describe("Variable Access", () => { 120 + it("should access scope variables", () => { 121 + scope.name = "Alice"; 122 + scope.age = 30; 123 + expect(evaluate("name", scope)).toBe("Alice"); 124 + expect(evaluate("age", scope)).toBe(30); 230 125 }); 231 126 232 - it("handles nested parentheses", () => { 233 - expect(evaluate("((5 + 3) * 2)", {})).toBe(16); 234 - expect(evaluate("(5 + (3 * 2))", {})).toBe(11); 235 - expect(evaluate("((2 + 3) * (4 + 5))", {})).toBe(45); 127 + it("should return undefined for missing variables", () => { 128 + expect(evaluate("missing", scope)).toBe(undefined); 236 129 }); 237 130 238 - it("overrides operator precedence", () => { 239 - expect(evaluate("2 + 3 * 4", {})).toBe(14); 240 - expect(evaluate("(2 + 3) * 4", {})).toBe(20); 131 + it("should handle variables in expressions", () => { 132 + scope.x = 10; 133 + scope.y = 5; 134 + expect(evaluate("x + y", scope)).toBe(15); 135 + expect(evaluate("x * y", scope)).toBe(50); 241 136 }); 242 137 }); 243 138 244 - describe("complex expressions", () => { 245 - it("evaluates combined arithmetic and comparison", () => { 246 - const scope = { count: 10, limit: 5 }; 247 - expect(evaluate("count * 2 > limit", scope)).toBe(true); 248 - expect(evaluate("count + 5 === 15", scope)).toBe(true); 249 - expect(evaluate("count - 3 < limit", scope)).toBe(false); 139 + describe("Property Access", () => { 140 + it("should access object properties with dot notation", () => { 141 + scope.user = { name: "Bob", age: 25 }; 142 + expect(evaluate("user.name", scope)).toBe("Bob"); 143 + expect(evaluate("user.age", scope)).toBe(25); 250 144 }); 251 145 252 - it("evaluates combined comparison and logical", () => { 253 - const scope = { age: 25, min: 18, max: 65 }; 254 - expect(evaluate("age >= min && age <= max", scope)).toBe(true); 255 - expect(evaluate("age < min || age > max", scope)).toBe(false); 146 + it("should access object properties with bracket notation", () => { 147 + scope.user = { name: "Charlie", age: 35 }; 148 + expect(evaluate("user['name']", scope)).toBe("Charlie"); 149 + expect(evaluate("user['age']", scope)).toBe(35); 256 150 }); 257 151 258 - it("evaluates complex nested expressions", () => { 259 - const scope = { a: 5, b: 3, c: 2, d: signal(10) }; 260 - expect(evaluate("(a + b) * c === d", scope)).toBe(false); 261 - expect(evaluate("a * b + c === d + 7", scope)).toBe(true); 262 - expect(evaluate("!(a < b) && c > 0", scope)).toBe(true); 263 - }); 264 - 265 - it("handles array length checks", () => { 266 - const scope = { items: [1, 2, 3] }; 267 - expect(evaluate("items.length === 3", scope)).toBe(true); 268 - expect(evaluate("items.length > 0", scope)).toBe(true); 269 - expect(evaluate("items.length === 0", scope)).toBe(false); 270 - }); 271 - }); 272 - 273 - describe("error handling", () => { 274 - it("returns undefined for invalid expressions", () => { 275 - expect(evaluate("@#$%", {})).toBe(undefined); 276 - }); 277 - 278 - it("handles null/undefined gracefully", () => { 279 - const scope = { nil: null, undef: undefined }; 280 - expect(evaluate("nil", scope)).toBe(null); 281 - expect(evaluate("undef", scope)).toBe(undefined); 282 - expect(evaluate("nil.property", scope)).toBe(undefined); 152 + it("should access nested properties", () => { 153 + scope.data = { user: { profile: { name: "Dave" } } }; 154 + expect(evaluate("data.user.profile.name", scope)).toBe("Dave"); 283 155 }); 284 156 285 - it("handles errors in complex expressions", () => { 286 - const result = evaluate("unclosed (", {}); 287 - expect(result).toBe(undefined); 157 + it("should access array elements", () => { 158 + scope.items = [10, 20, 30]; 159 + expect(evaluate("items[0]", scope)).toBe(10); 160 + expect(evaluate("items[1]", scope)).toBe(20); 161 + expect(evaluate("items[2]", scope)).toBe(30); 288 162 }); 289 163 }); 290 164 291 - describe("whitespace handling", () => { 292 - it("ignores whitespace", () => { 293 - expect(evaluate(" 5 + 3 ", {})).toBe(8); 294 - expect(evaluate("\n10\n*\n2\n", {})).toBe(20); 295 - expect(evaluate(" true && false ", {})).toBe(false); 296 - }); 297 - 298 - it("preserves whitespace in strings", () => { 299 - expect(evaluate("'hello world'", {})).toBe("hello world"); 300 - expect(evaluate("' spaces '", {})).toBe(" spaces "); 165 + describe("Function Calls", () => { 166 + it("should call scope functions", () => { 167 + scope.double = (x: number) => x * 2; 168 + expect(evaluate("double(5)", scope)).toBe(10); 301 169 }); 302 - }); 303 170 304 - describe("real-world use cases", () => { 305 - it("evaluates todo app conditions", () => { 306 - const scope = { todos: signal([{ completed: true }, { completed: false }]), filter: "active" }; 307 - 308 - expect(evaluate("todos.length > 0", scope)).toBe(true); 309 - expect(evaluate("todos.length === 0", scope)).toBe(false); 310 - expect(evaluate("filter === 'active'", scope)).toBe(true); 171 + it("should call functions with multiple arguments", () => { 172 + scope.add = (a: number, b: number) => a + b; 173 + expect(evaluate("add(3, 7)", scope)).toBe(10); 311 174 }); 312 175 313 - it("evaluates form validation", () => { 314 - const scope = { email: signal("user@example.com"), password: signal("secret123"), agreed: signal(true) }; 315 - 316 - expect(evaluate("email.length > 0", scope)).toBe(true); 317 - expect(evaluate("password.length >= 8", scope)).toBe(true); 318 - expect(evaluate("agreed === true", scope)).toBe(true); 319 - expect(evaluate("email.length > 0 && password.length >= 8 && agreed", scope)).toBe(true); 176 + it("should call object methods", () => { 177 + scope.calc = { multiply: (a: number, b: number) => a * b }; 178 + expect(evaluate("calc.multiply(4, 5)", scope)).toBe(20); 320 179 }); 321 180 322 - it("evaluates pagination", () => { 323 - const scope = { page: signal(2), totalPages: 5, items: [1, 2, 3] }; 324 - 325 - expect(evaluate("page > 1", scope)).toBe(true); 326 - expect(evaluate("page < totalPages", scope)).toBe(true); 327 - expect(evaluate("items.length > 0", scope)).toBe(true); 328 - expect(evaluate("(page - 1) * 10", scope)).toBe(10); 181 + it("should call safe global functions", () => { 182 + expect(evaluate("Math.max(10, 20)", scope)).toBe(20); 183 + expect(evaluate("Math.min(10, 20)", scope)).toBe(10); 184 + expect(evaluate("Math.abs(-5)", scope)).toBe(5); 329 185 }); 330 186 }); 331 187 332 - describe("method calls", () => { 333 - it("calls methods on objects", () => { 334 - const scope = { text: "hello world" }; 335 - expect(evaluate("text.toUpperCase()", scope)).toBe("HELLO WORLD"); 336 - expect(evaluate("text.substring(0, 5)", scope)).toBe("hello"); 337 - }); 338 - 339 - it("calls methods on arrays", () => { 340 - const scope = { items: [1, 2, 3, 4, 5] }; 341 - expect(evaluate("items.slice(1, 3)", scope)).toEqual([2, 3]); 342 - expect(evaluate("items.indexOf(3)", scope)).toBe(2); 343 - }); 344 - 345 - it("calls methods on signals", () => { 346 - const count = signal(5); 347 - const scope = { count }; 348 - expect(evaluate("count.get()", scope)).toBe(5); 349 - }); 350 - 351 - it("calls methods on signal values", () => { 352 - const email = signal("test@example.com"); 353 - const text = signal("hello world"); 354 - const items = signal([1, 2, 3, 4]); 355 - const scope = { email, text, items }; 356 - 357 - expect(evaluate("email.includes('@')", scope)).toBe(true); 358 - expect(evaluate("email.includes('xyz')", scope)).toBe(false); 359 - expect(evaluate("text.toUpperCase()", scope)).toBe("HELLO WORLD"); 360 - expect(evaluate("text.substring(0, 5)", scope)).toBe("hello"); 361 - expect(evaluate("items.indexOf(3)", scope)).toBe(2); 362 - expect(evaluate("items.slice(1, 3)", scope)).toEqual([2, 3]); 188 + describe("Array Literals", () => { 189 + it("should create array literals", () => { 190 + const result = evaluate("[1, 2, 3]", scope); 191 + expect(result).toEqual([1, 2, 3]); 363 192 }); 364 193 365 - it("calls methods with multiple arguments", () => { 366 - const scope = { text: "one,two,three" }; 367 - expect(evaluate("text.split(',')", scope)).toEqual(["one", "two", "three"]); 194 + it("should handle empty arrays", () => { 195 + const result = evaluate("[]", scope); 196 + expect(result).toEqual([]); 368 197 }); 369 198 370 - it("chains method calls", () => { 371 - const scope = { text: " hello " }; 372 - expect(evaluate("text.trim().toUpperCase()", scope)).toBe("HELLO"); 199 + it("should handle arrays with expressions", () => { 200 + scope.x = 10; 201 + const result = evaluate("[x, x + 1, x + 2]", scope); 202 + expect(result).toEqual([10, 11, 12]); 373 203 }); 374 204 375 - it("calls methods with no arguments", () => { 376 - const scope = { arr: [1, 2, 3] }; 377 - expect(evaluate("arr.reverse()", scope)).toEqual([3, 2, 1]); 205 + it("should handle spread in arrays", () => { 206 + scope.arr = [2, 3, 4]; 207 + const result = evaluate("[1, ...arr, 5]", scope); 208 + expect(result).toEqual([1, 2, 3, 4, 5]); 378 209 }); 379 210 }); 380 211 381 - describe("ternary operator", () => { 382 - it("evaluates simple ternary expressions", () => { 383 - expect(evaluate("true ? 'yes' : 'no'", {})).toBe("yes"); 384 - expect(evaluate("false ? 'yes' : 'no'", {})).toBe("no"); 212 + describe("Object Literals", () => { 213 + it("should create object literals", () => { 214 + const result = evaluate("{ name: 'Alice', age: 30 }", scope); 215 + expect(result).toEqual({ name: "Alice", age: 30 }); 385 216 }); 386 217 387 - it("evaluates ternary with variables", () => { 388 - const scope = { count: 5, limit: 10 }; 389 - expect(evaluate("count > 0 ? 'positive' : 'zero or negative'", scope)).toBe("positive"); 390 - expect(evaluate("count === limit ? 'equal' : 'not equal'", scope)).toBe("not equal"); 218 + it("should handle empty objects", () => { 219 + const result = evaluate("{}", scope); 220 + expect(result).toEqual({}); 391 221 }); 392 222 393 - it("evaluates ternary with signals", () => { 394 - const scope = { count: signal(1) }; 395 - expect(evaluate("count === 1 ? 'single' : 'multiple'", scope)).toBe("single"); 223 + it("should handle objects with computed values", () => { 224 + scope.x = 10; 225 + const result = evaluate("{ value: x, double: x * 2 }", scope); 226 + expect(result).toEqual({ value: 10, double: 20 }); 396 227 }); 397 228 398 - it("evaluates nested ternary expressions", () => { 399 - const scope = { value: 5 }; 400 - expect(evaluate("value < 0 ? 'negative' : value === 0 ? 'zero' : 'positive'", scope)).toBe("positive"); 401 - }); 402 - 403 - it("evaluates ternary with complex conditions", () => { 404 - const scope = { age: 25, min: 18, max: 65 }; 405 - expect(evaluate("age >= min && age <= max ? 'valid' : 'invalid'", scope)).toBe("valid"); 406 - }); 407 - 408 - it("evaluates ternary for pluralization", () => { 409 - expect(evaluate("1 === 1 ? 'item' : 'items'", {})).toBe("item"); 410 - expect(evaluate("5 === 1 ? 'item' : 'items'", {})).toBe("items"); 229 + it("should handle spread in objects", () => { 230 + scope.base = { a: 1, b: 2 }; 231 + const result = evaluate("{ ...base, c: 3 }", scope); 232 + expect(result).toEqual({ a: 1, b: 2, c: 3 }); 411 233 }); 412 234 }); 413 235 414 - describe("arrow functions", () => { 415 - it("evaluates single-parameter arrow functions", () => { 416 - const scope = { items: [1, 2, 3, 4, 5] }; 417 - const result = evaluate("items.filter(x => x > 2)", scope); 418 - expect(result).toEqual([3, 4, 5]); 419 - }); 420 - 421 - it("evaluates multi-parameter arrow functions", () => { 422 - const scope = { items: ["a", "b", "c"] }; 423 - const result = evaluate("items.map((item, index) => index)", scope); 424 - expect(result).toEqual([0, 1, 2]); 425 - }); 426 - 427 - it("captures scope in arrow functions", () => { 428 - const scope = { todos: [{ completed: false }, { completed: true }, { completed: false }] }; 429 - const result = evaluate("todos.filter(t => !t.completed)", scope); 430 - expect(result).toEqual([{ completed: false }, { completed: false }]); 236 + describe("Arrow Functions", () => { 237 + it("should support arrow functions", () => { 238 + const fn = evaluate("(x) => x * 2", scope) as (x: number) => number; 239 + expect(fn(5)).toBe(10); 431 240 }); 432 241 433 - it("evaluates arrow functions with complex expressions", () => { 434 - const scope = { items: [1, 2, 3] }; 435 - const result = evaluate("items.map(x => x * 2 + 1)", scope); 436 - expect(result).toEqual([3, 5, 7]); 242 + it("should support arrow functions with no parameters", () => { 243 + const fn = evaluate("() => 42", scope) as () => number; 244 + expect(fn()).toBe(42); 437 245 }); 438 246 439 - it("evaluates arrow functions with property access", () => { 440 - const scope = { users: [{ name: "Alice", age: 25 }, { name: "Bob", age: 30 }] }; 441 - const result = evaluate("users.map(u => u.name)", scope); 442 - expect(result).toEqual(["Alice", "Bob"]); 443 - }); 444 - 445 - it("evaluates nested arrow functions", () => { 446 - const scope = { matrix: [[1, 2], [3, 4]] }; 447 - const result = evaluate("matrix.map(row => row.map(n => n * 2))", scope); 448 - expect(result).toEqual([[2, 4], [6, 8]]); 247 + it("should support arrow functions with multiple parameters", () => { 248 + const fn = evaluate("(a, b) => a + b", scope) as (a: number, b: number) => number; 249 + expect(fn(3, 7)).toBe(10); 449 250 }); 450 251 451 - it("evaluates arrow functions with no parameters", () => { 452 - const scope = { arr: [1, 2, 3] }; 453 - const mapper = evaluate("() => 42", scope); 454 - expect(typeof mapper).toBe("function"); 455 - expect((mapper as () => number)()).toBe(42); 252 + it("should support arrow functions that capture scope", () => { 253 + scope.multiplier = 3; 254 + const fn = evaluate("(x) => x * multiplier", scope) as (x: number) => number; 255 + expect(fn(5)).toBe(15); 456 256 }); 457 257 }); 458 258 459 - describe("object literals", () => { 460 - it("evaluates empty object literals", () => { 461 - expect(evaluate("{}", {})).toEqual({}); 259 + describe("Signal Auto-Unwrapping", () => { 260 + it("should auto-unwrap signals on read", () => { 261 + scope.count = signal(10); 262 + expect(evaluate("count", scope)).toBe(10); 462 263 }); 463 264 464 - it("evaluates simple object literals", () => { 465 - expect(evaluate("{ active: true, disabled: false }", {})).toEqual({ active: true, disabled: false }); 265 + it("should auto-unwrap signals in expressions", () => { 266 + scope.count = signal(5); 267 + expect(evaluate("count + 10", scope)).toBe(15); 268 + expect(evaluate("count * 2", scope)).toBe(10); 466 269 }); 467 270 468 - it("evaluates object literals with variables", () => { 469 - const scope = { isActive: true, count: 5 }; 470 - expect(evaluate("{ active: isActive, num: count }", scope)).toEqual({ active: true, num: 5 }); 271 + it("should auto-unwrap nested signal properties", () => { 272 + scope.user = signal({ name: "Alice", age: 30 }); 273 + expect(evaluate("user.name", scope)).toBe("Alice"); 274 + expect(evaluate("user.age", scope)).toBe(30); 471 275 }); 472 276 473 - it("evaluates object literals with string keys", () => { 474 - expect(evaluate("{ 'first-name': 'Alice', 'last-name': 'Smith' }", {})).toEqual({ 475 - "first-name": "Alice", 476 - "last-name": "Smith", 477 - }); 478 - }); 479 - 480 - it("evaluates object literals with expressions", () => { 481 - const scope = { count: 5 }; 482 - expect(evaluate("{ value: count * 2, label: 'items' }", scope)).toEqual({ value: 10, label: "items" }); 483 - }); 484 - 485 - it("evaluates object literals with nested objects", () => { 486 - expect(evaluate("{ user: { name: 'Bob', age: 30 } }", {})).toEqual({ user: { name: "Bob", age: 30 } }); 277 + it("should allow signal.set() calls", () => { 278 + scope.count = signal(10); 279 + evaluateStatements("count.set(20)", scope); 280 + expect((scope.count as Signal<number>).get()).toBe(20); 487 281 }); 488 282 }); 489 283 490 - describe("array literals", () => { 491 - it("evaluates empty array literals", () => { 492 - expect(evaluate("[]", {})).toEqual([]); 493 - }); 284 + describe("Expression Caching", () => { 285 + it("should cache compiled expressions", () => { 286 + const expr = "x + y"; 287 + scope.x = 10; 288 + scope.y = 5; 494 289 495 - it("evaluates simple array literals", () => { 496 - expect(evaluate("[1, 2, 3]", {})).toEqual([1, 2, 3]); 497 - expect(evaluate("['a', 'b', 'c']", {})).toEqual(["a", "b", "c"]); 498 - }); 290 + const result1 = evaluate(expr, scope); 291 + const result2 = evaluate(expr, scope); 499 292 500 - it("evaluates array literals with variables", () => { 501 - const scope = { x: 5, y: 10 }; 502 - expect(evaluate("[x, y, 15]", scope)).toEqual([5, 10, 15]); 293 + expect(result1).toBe(15); 294 + expect(result2).toBe(15); 503 295 }); 504 296 505 - it("evaluates array literals with expressions", () => { 506 - const scope = { count: 3 }; 507 - expect(evaluate("[count, count * 2, count * 3]", scope)).toEqual([3, 6, 9]); 508 - }); 297 + it("should cache statement expressions separately", () => { 298 + scope.x = 10; 509 299 510 - it("evaluates nested array literals", () => { 511 - expect(evaluate("[[1, 2], [3, 4]]", {})).toEqual([[1, 2], [3, 4]]); 512 - }); 300 + evaluateStatements("x = 20", scope); 301 + expect(scope.x).toBe(20); 513 302 514 - it("evaluates mixed type array literals", () => { 515 - expect(evaluate("[1, 'two', true, null]", {})).toEqual([1, "two", true, null]); 303 + const result = evaluate("x", scope); 304 + expect(result).toBe(20); 516 305 }); 517 306 }); 518 307 519 - describe("spread operator", () => { 520 - it("spreads arrays in array literals", () => { 521 - const scope = { items: [2, 3, 4] }; 522 - expect(evaluate("[1, ...items, 5]", scope)).toEqual([1, 2, 3, 4, 5]); 308 + describe("Statement Evaluation", () => { 309 + it("should execute single statements", () => { 310 + scope.x = 10; 311 + evaluateStatements("x = 20", scope); 312 + expect(scope.x).toBe(20); 523 313 }); 524 314 525 - it("spreads multiple arrays", () => { 526 - const scope = { first: [1, 2], second: [3, 4] }; 527 - expect(evaluate("[...first, ...second]", scope)).toEqual([1, 2, 3, 4]); 315 + it("should execute multiple statements", () => { 316 + scope.x = 1; 317 + scope.y = 2; 318 + evaluateStatements("x = 10; y = 20", scope); 319 + expect(scope.x).toBe(10); 320 + expect(scope.y).toBe(20); 528 321 }); 529 322 530 - it("spreads objects in object literals", () => { 531 - const scope = { user: { name: "Alice", age: 25 } }; 532 - expect(evaluate("{ ...user, age: 26 }", scope)).toEqual({ name: "Alice", age: 26 }); 533 - }); 534 - 535 - it("spreads with new properties", () => { 536 - const scope = { base: { a: 1, b: 2 } }; 537 - expect(evaluate("{ ...base, c: 3 }", scope)).toEqual({ a: 1, b: 2, c: 3 }); 538 - }); 539 - 540 - it("handles multiple spreads in objects", () => { 541 - const scope = { obj1: { a: 1 }, obj2: { b: 2 } }; 542 - expect(evaluate("{ ...obj1, ...obj2, c: 3 }", scope)).toEqual({ a: 1, b: 2, c: 3 }); 323 + it("should allow function calls in statements", () => { 324 + scope.log = []; 325 + scope.add = (value: number) => { 326 + (scope.log as number[]).push(value); 327 + }; 328 + evaluateStatements("add(1); add(2); add(3)", scope); 329 + expect(scope.log).toEqual([1, 2, 3]); 543 330 }); 544 331 }); 545 332 546 - describe("enhanced evaluator integration", () => { 547 - it("combines method calls with ternary operators", () => { 548 - const scope = { text: "hello", minLength: 3 }; 549 - expect(evaluate("text.length >= minLength ? text.toUpperCase() : text", scope)).toBe("HELLO"); 333 + describe("Error Handling", () => { 334 + it("should throw EvaluationError for invalid syntax", () => { 335 + expect(() => evaluate("1 +", scope)).toThrow(EvaluationError); 550 336 }); 551 337 552 - it("uses arrow functions with object literals", () => { 553 - const scope = { items: [1, 2, 3] }; 554 - const result = evaluate("items.map(n => ({ value: n, doubled: n * 2 }))", scope); 555 - expect(result).toEqual([{ value: 1, doubled: 2 }, { value: 2, doubled: 4 }, { value: 3, doubled: 6 }]); 556 - }); 557 - 558 - it("uses spread in method call results", () => { 559 - const scope = { todos: [{ id: 1 }, { id: 2 }], newTodo: { id: 3 } }; 560 - expect(evaluate("[...todos, newTodo]", scope)).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); 561 - }); 562 - 563 - it("evaluates complex todo app mutations", () => { 564 - const scope = { todos: [{ id: 1, completed: false }, { id: 2, completed: true }] }; 565 - const result = evaluate("todos.filter(t => !t.completed)", scope); 566 - expect(result).toEqual([{ id: 1, completed: false }]); 338 + it("should throw EvaluationError for runtime errors", () => { 339 + expect(() => evaluate("undefined.property", scope)).toThrow(EvaluationError); 567 340 }); 568 341 569 - it("evaluates signal mutations with spread", () => { 570 - const count = signal(5); 571 - const scope = { count }; 572 - expect(evaluate("count.get() + 1", scope)).toBe(6); 342 + it("should include expression in error message", () => { 343 + try { 344 + evaluate("1 +", scope); 345 + } catch (error) { 346 + expect(error).toBeInstanceOf(EvaluationError); 347 + expect((error as EvaluationError).expr).toBe("1 +"); 348 + } 573 349 }); 574 350 575 - it("handles complex class binding expressions", () => { 576 - const scope = { isActive: true, isDisabled: false }; 577 - expect(evaluate("isActive ? 'active' : ''", scope)).toBe("active"); 351 + it("should preserve original error cause", () => { 352 + try { 353 + evaluate("undefined.property", scope); 354 + } catch (error) { 355 + expect(error).toBeInstanceOf(EvaluationError); 356 + expect((error as EvaluationError).cause).toBeDefined(); 357 + } 578 358 }); 579 359 }); 580 360 });
+1 -1
lib/test/core/scope-vars.test.ts
··· 267 267 probe("null.toString()", () => {}); 268 268 269 269 expect(consoleError).toHaveBeenCalledWith( 270 - expect.stringContaining("Error evaluating expression"), 270 + expect.stringContaining("Error in $probe expression"), 271 271 expect.any(Error), 272 272 ); 273 273
+1 -4
lib/test/integration/global-state.test.ts
··· 459 459 460 460 charge(); 461 461 462 - expect(consoleError).toHaveBeenCalledWith( 463 - expect.stringContaining("Error evaluating expression"), 464 - expect.any(Error), 465 - ); 462 + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("Error in data-volt-init"), expect.any(Error)); 466 463 467 464 consoleError.mockRestore(); 468 465 });
+2 -1
lib/tsconfig.build.json
··· 8 8 "outDir": "./dist", 9 9 "rootDir": "./src" 10 10 }, 11 - "include": ["src"] 11 + "include": ["src"], 12 + "exclude": ["src/main.ts"] 12 13 }
+34 -29
lib/vite.config.ts
··· 1 1 import path from "node:path"; 2 2 import { fileURLToPath } from "node:url"; 3 - import { type BuildEnvironmentOptions, defineConfig } from "vite"; 3 + import { type BuildEnvironmentOptions, defineConfig, type LibraryOptions } from "vite"; 4 4 import { type ViteUserConfig } from "vitest/config"; 5 5 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 6 ··· 22 22 }; 23 23 24 24 const buildOptions = (mode: string): BuildEnvironmentOptions => { 25 - const isLibBuild = mode === "lib" || mode === "lib:min"; 26 - const shouldMinify = mode === "lib:min"; 25 + const [baseMode, ...flags] = mode.split(":"); 26 + const isLibBuild = baseMode === "lib"; 27 + const shouldMinify = flags.includes("min"); 28 + const target = flags.find((flag) => flag !== "min") ?? "all"; 27 29 28 - return { 29 - minify: shouldMinify ? "oxc" : false, 30 - ...(isLibBuild 31 - ? { 32 - lib: { 33 - entry: { voltx: path.resolve(__dirname, "src/index.ts"), debug: path.resolve(__dirname, "src/debug.ts") }, 34 - name: "VoltX", 35 - formats: ["es"], 36 - fileName: (format, entryName) => { 37 - const suffix = shouldMinify ? ".min.js" : ".js"; 38 - return `${entryName}${suffix}`; 39 - }, 40 - }, 41 - rolldownOptions: { 42 - output: { assetFileNames: "voltx.[ext]", manualChunks: undefined, preserveModules: false }, 43 - onwarn(warning, warn) { 44 - if (warning.code === "UNUSED_EXTERNAL_IMPORT") return; 45 - warn(warning); 46 - }, 47 - }, 48 - } 49 - : {}), 30 + if (!isLibBuild) return { minify: shouldMinify ? "oxc" : false }; 31 + 32 + const entry: LibraryOptions["entry"] = {}; 33 + if (target === "all" || target === "voltx") entry.voltx = path.resolve(__dirname, "src/index.ts"); 34 + if (target === "all" || target === "debug") entry.debug = path.resolve(__dirname, "src/debug.ts"); 35 + 36 + if (Object.keys(entry).length === 0) { 37 + entry.voltx = path.resolve(__dirname, "src/index.ts"); 38 + } 39 + 40 + const lib: BuildEnvironmentOptions["lib"] = { 41 + entry, 42 + name: "VoltX", 43 + formats: ["es"], 44 + fileName: (format, entryName) => { 45 + const suffix = shouldMinify ? ".min.js" : ".js"; 46 + return `${entryName}${suffix}`; 47 + }, 48 + }; 49 + 50 + const rolldownOptions: BuildEnvironmentOptions["rolldownOptions"] = { 51 + output: { assetFileNames: "voltx.[ext]" }, 52 + onwarn(warning, warn) { 53 + if (warning.code === "UNUSED_EXTERNAL_IMPORT") return; 54 + warn(warning); 55 + }, 50 56 }; 57 + 58 + return { minify: shouldMinify ? "oxc" : false, lib, rolldownOptions }; 51 59 }; 52 60 53 61 export default defineConfig(({ mode }) => ({ ··· 61 69 "$vebug": path.resolve(__dirname, "./src/debug.ts"), 62 70 }, 63 71 }, 64 - build: { 65 - ...buildOptions(mode), 66 - emptyOutDir: false, // Don't clear dist/ to preserve TypeScript declarations 67 - }, 72 + build: { ...buildOptions(mode), emptyOutDir: false }, 68 73 test, 69 74 plugins: [], 70 75 }));
+6 -3
pnpm-lock.yaml
··· 37 37 version: 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) 38 38 vitest: 39 39 specifier: ^3.2.4 40 - version: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0)(terser@5.44.0)(yaml@2.8.1) 40 + version: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1) 41 41 42 42 dev: 43 43 dependencies: ··· 105 105 postcss-import: 106 106 specifier: ^16.1.1 107 107 version: 16.1.1(postcss@8.5.6) 108 + terser: 109 + specifier: ^5.44.0 110 + version: 5.44.0 108 111 vite: 109 112 specifier: npm:rolldown-vite@7.1.14 110 113 version: rolldown-vite@7.1.14(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) ··· 3706 3709 std-env: 3.10.0 3707 3710 test-exclude: 7.0.1 3708 3711 tinyrainbow: 2.0.0 3709 - vitest: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0)(terser@5.44.0)(yaml@2.8.1) 3712 + vitest: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1) 3710 3713 transitivePeerDependencies: 3711 3714 - supports-color 3712 3715 ··· 5652 5655 - universal-cookie 5653 5656 - yaml 5654 5657 5655 - vitest@3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0)(terser@5.44.0)(yaml@2.8.1): 5658 + vitest@3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1): 5656 5659 dependencies: 5657 5660 '@types/chai': 5.2.2 5658 5661 '@vitest/expect': 3.2.4