a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1export type CleanupFunction = () => void;
2
3export type Scope = Record<string, unknown>;
4
5export type EvaluateOpts = { unwrapSignals?: boolean };
6
7/**
8 * Context object available to all bindings
9 */
10export type BindingContext = { element: Element; scope: Scope; cleanups: CleanupFunction[] };
11
12/**
13 * Context object provided to plugin handlers.
14 * Contains utilities and references for implementing custom bindings.
15 */
16export interface PluginContext {
17 /**
18 * The DOM element the plugin is bound to
19 */
20 element: Element;
21
22 /**
23 * The scope object containing signals and data
24 */
25 scope: Scope;
26
27 /**
28 * Register a cleanup function to be called on unmount.
29 * Plugins should use this to clean up subscriptions, event listeners, etc.
30 */
31 addCleanup(fn: CleanupFunction): void;
32
33 /**
34 * Find a signal in the scope by property path.
35 * Returns undefined if not found or if the value is not a signal.
36 */
37 findSignal(path: string): Signal<unknown> | undefined;
38
39 /**
40 * Evaluate an expression against the scope.
41 * Handles simple property paths, literals, and signal unwrapping.
42 */
43 evaluate(expression: string, options?: EvaluateOpts): unknown;
44
45 /**
46 * Lifecycle hooks for plugin-specific mount/unmount behavior
47 */
48 lifecycle: PluginLifecycle;
49}
50
51/**
52 * Plugin handler function signature.
53 * Receives context and the attribute value, performs binding setup.
54 */
55export type PluginHandler = (context: PluginContext, value: string) => void;
56
57/**
58 * A reactive primitive that notifies subscribers when its value changes.
59 */
60export interface Signal<T> {
61 /**
62 * Get the current value of the signal.
63 */
64 get(): T;
65
66 /**
67 * Update the signal's value.
68 *
69 * If the new value differs from the current value, subscribers will be notified.
70 */
71 set(value: T): void;
72
73 /**
74 * Subscribe to changes in the signal's value.
75 *
76 * The callback is invoked with the new value whenever it changes.
77 *
78 * Returns an unsubscribe function to remove the subscription.
79 */
80 subscribe(callback: (value: T) => void): () => void;
81}
82
83/**
84 * A computed signal that derives its value from other signals.
85 */
86export interface ComputedSignal<T> {
87 /**
88 * Get the current computed value.
89 */
90 get(): T;
91
92 /**
93 * Subscribe to changes in the computed value.
94 *
95 * Returns an unsubscribe function to remove the subscription.
96 */
97 subscribe(callback: (value: T) => void): () => void;
98}
99
100/**
101 * Utility type to unwrap reactive proxies and get the original type.
102 * Since reactive() returns a transparent proxy, this is mostly for documentation.
103 */
104export type UnwrapReactive<T> = T extends object ? T : never;
105
106/**
107 * Utility type for reactive arrays with enhanced type safety.
108 */
109export type ReactiveArray<T> = T[];
110
111/**
112 * Utility type to check if a value is reactive (has the __v_isReactive marker).
113 */
114export type IsReactive<T> = T extends { __v_isReactive: true } ? true : false;
115
116/**
117 * Storage adapter interface for custom persistence backends
118 */
119export interface StorageAdapter {
120 get(key: string): Promise<unknown> | unknown;
121 set(key: string, value: unknown): Promise<void> | void;
122 remove(key: string): Promise<void> | void;
123}
124
125/**
126 * Information about a mounted Volt root after charging
127 *
128 * element: The root element that was mounted
129 * scope: The reactive scope created for this root
130 * cleanup: Cleanup function to unmount this root
131 */
132export type ChargedRoot = { element: Element; scope: Scope; cleanup: CleanupFunction };
133
134/**
135 * Result of charging Volt roots
136 *
137 * roots: Array of all charged roots
138 * cleanup: Cleanup function to unmount all roots
139 */
140export type ChargeResult = { roots: ChargedRoot[]; cleanup: CleanupFunction };
141
142export type Dep = { get: () => unknown; subscribe: (callback: (value: unknown) => void) => () => void };
143
144/**
145 * Options for configuring async effects
146 */
147export interface AsyncEffectOptions {
148 /**
149 * Enable automatic AbortController integration.
150 * When true, provides an AbortSignal to the effect function for canceling async operations.
151 */
152 abortable?: boolean;
153
154 /**
155 * Debounce delay in milliseconds.
156 * Effect execution is delayed until this duration has passed without dependencies changing.
157 */
158 debounce?: number;
159
160 /**
161 * Throttle delay in milliseconds.
162 * Effect execution is rate-limited to at most once per this duration.
163 */
164 throttle?: number;
165
166 /**
167 * Error handler for async effect failures.
168 * Receives the error and a retry function.
169 */
170 onError?: (error: Error, retry: () => void) => void;
171
172 /**
173 * Number of automatic retry attempts on error.
174 * Defaults to 0 (no retries).
175 */
176 retries?: number;
177
178 /**
179 * Delay in milliseconds between retry attempts.
180 * Defaults to 0 (immediate retry).
181 */
182 retryDelay?: number;
183}
184
185/**
186 * Async effect function signature.
187 * Receives an optional AbortSignal when abortable option is enabled.
188 * Can return a cleanup function or a Promise that resolves to a cleanup function.
189 */
190export type AsyncEffectFunction = (signal?: AbortSignal) => Promise<void | (() => void)>;
191
192export type LifecycleHookCallback = () => void;
193export type MountHookCallback = (root: Element, scope: Scope) => void;
194export type UnmountHookCallback = (root: Element) => void;
195export type ElementMountHookCallback = (element: Element, scope: Scope) => void;
196export type ElementUnmountHookCallback = (element: Element) => void;
197export type BindingHookCallback = (element: Element, bindingName: string) => void;
198export type GlobalHookName = "beforeMount" | "afterMount" | "beforeUnmount" | "afterUnmount";
199
200/**
201 * Extended plugin context with lifecycle hooks
202 */
203export interface PluginLifecycle {
204 /**
205 * Register a callback to run when the plugin is initialized for an element
206 */
207 onMount: (callback: LifecycleHookCallback) => void;
208
209 /**
210 * Register a callback to run when the element is being unmounted
211 */
212 onUnmount: (callback: LifecycleHookCallback) => void;
213
214 /**
215 * Register a callback to run before the binding is created
216 */
217 beforeBinding: (callback: LifecycleHookCallback) => void;
218
219 /**
220 * Register a callback to run after the binding is created
221 */
222 afterBinding: (callback: LifecycleHookCallback) => void;
223}
224
225export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
226
227/**
228 * Strategies for swapping response content into the DOM
229 *
230 * - innerHTML: Replace the target's inner HTML (default)
231 * - outerHTML: Replace the target element entirely
232 * - beforebegin: Insert before the target element
233 * - afterbegin: Insert at the start of the target's content
234 * - beforeend: Insert at the end of the target's content
235 * - afterend: Insert after the target element
236 * - delete: Remove the target element
237 * - none: No DOM update (for side effects only)
238 */
239export type SwapStrategy =
240 | "innerHTML"
241 | "outerHTML"
242 | "beforebegin"
243 | "afterbegin"
244 | "beforeend"
245 | "afterend"
246 | "delete"
247 | "none";
248
249export type RequestConfig = {
250 method: HttpMethod;
251 url: string;
252 headers?: Record<string, string>;
253 body?: string | FormData;
254 target?: string | Element;
255 swap?: SwapStrategy;
256};
257
258export type HttpResponse = {
259 status: number;
260 statusText: string;
261 headers: Headers;
262 html?: string;
263 json?: unknown;
264 ok: boolean;
265};
266
267/**
268 * Configuration parsed from element attributes
269 */
270export type ParsedHttpConfig = {
271 trigger: string;
272 target: string | Element;
273 swap: SwapStrategy;
274 headers: Record<string, string>;
275 retry?: RetryConfig;
276 indicator?: string;
277};
278
279/**
280 * Retry configuration for HTTP requests
281 */
282export type RetryConfig = {
283 /**
284 * Maximum number of retry attempts
285 */
286 maxAttempts: number;
287
288 /**
289 * Initial delay in milliseconds before first retry
290 */
291 initialDelay: number;
292};
293
294export type HydrateOptions = { rootSelector?: string; skipHydrated?: boolean };
295
296/**
297 * Serialized scope data structure for SSR
298 */
299export type SerializedScope = Record<string, unknown>;
300
301export type HydrateResult = ChargeResult;
302
303/**
304 * Element-level lifecycle tracking for per-element hooks
305 */
306export type ElementLifecycleState = {
307 isMounted: boolean;
308 bindings: Set<string>;
309 onMount: Set<() => void>;
310 onUnmount: Set<() => void>;
311};
312
313export type AnySignal = Signal<unknown> | ComputedSignal<unknown>;
314
315export type GraphNode = {
316 signal: AnySignal;
317 id: string;
318 name?: string;
319 type: SignalType;
320 value: unknown;
321 dependencies: string[];
322 dependents: string[];
323};
324
325export type DepGraph = { nodes: GraphNode[]; edges: Array<{ from: string; to: string }> };
326
327export type SignalType = "signal" | "computed" | "reactive";
328
329export type SignalMetadata = { id: string; type: SignalType; name?: string; createdAt: number; stackTrace?: string };
330
331export type FormControlElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
332
333/**
334 * Throttle/Debounced function
335 */
336export type TimedFunction<A extends unknown[]> = ((...args: A) => void) & { cancel: () => void };
337
338/**
339 * Represents a parsed modifier with optional numeric value
340 */
341export type Modifier = { name: string; value?: number };
342
343/**
344 * Result of parsing an attribute name with modifiers
345 */
346export type ParsedAttribute = { baseName: string; modifiers: Modifier[] };
347
348/**
349 * Registry mapping pin names to DOM elements within a scope
350 */
351export type PinRegistry = Map<string, Element>;
352
353/**
354 * Metadata associated with a reactive scope.
355 * Stored externally via WeakMap to avoid polluting scope object.
356 */
357export type ScopeMetadata = {
358 /**
359 * The root element that owns this scope
360 */
361 origin: Element;
362
363 /**
364 * Registry of pinned elements (data-volt-pin)
365 */
366 pins: PinRegistry;
367
368 /**
369 * Counter for generating unique IDs within this scope
370 */
371 uidCounter: number;
372
373 /**
374 * Optional parent scope reference (for debugging/inspection)
375 */
376 parent?: Scope;
377};
378
379/**
380 * Global reactive store interface.
381 * Holds signals accessible across all scopes via $store.
382 */
383export interface GlobalStore {
384 /**
385 * Internal signal registry
386 */
387 readonly _signals: Map<string, Signal<unknown>>;
388
389 /**
390 * Get a signal value from the store
391 */
392 get<T = unknown>(key: string): T | undefined;
393
394 /**
395 * Set a signal value in the store.
396 * Creates a new signal if the key doesn't exist.
397 */
398 set<T = unknown>(key: string, value: T): void;
399
400 /**
401 * Check if a key exists in the store
402 */
403 has(key: string): boolean;
404
405 /**
406 * Access signals directly (for advanced use)
407 */
408 [key: string]: unknown;
409}
410
411/**
412 * Function signature for $pulse() - microtask scheduler
413 */
414export type PulseFunction = (callback: () => void) => void;
415
416/**
417 * Function signature for $uid() - unique ID generator
418 */
419export type UidFunction = (prefix?: string) => string;
420
421/**
422 * Function signature for $arc() - CustomEvent dispatcher
423 */
424export type ArcFunction = (eventName: string, detail?: unknown) => void;
425
426/**
427 * Function signature for $probe() - reactive observer
428 */
429export type ProbeFunction = (expression: string, callback: (value: unknown) => void) => CleanupFunction;
430
431/**
432 * Configuration for a single transition phase (enter or leave)
433 */
434export type TransitionPhase = {
435 /**
436 * Initial CSS properties (applied immediately)
437 */
438 from?: Record<string, string | number>;
439
440 /**
441 * Target CSS properties (animated to)
442 */
443 to?: Record<string, string | number>;
444
445 /**
446 * Duration in milliseconds (default: 300)
447 */
448 duration?: number;
449
450 /**
451 * Delay in milliseconds (default: 0)
452 */
453 delay?: number;
454
455 /**
456 * CSS easing function (default: 'ease')
457 */
458 easing?: string;
459
460 /**
461 * CSS classes to apply during this phase
462 */
463 classes?: string[];
464};
465
466/**
467 * Complete transition preset with enter and leave phases
468 */
469export type TransitionPreset = {
470 /**
471 * Configuration for enter transition
472 */
473 enter: TransitionPhase;
474
475 /**
476 * Configuration for leave transition
477 */
478 leave: TransitionPhase;
479};
480
481/**
482 * Parsed transition value with preset and modifiers
483 */
484export type ParsedTransition = {
485 /**
486 * The transition preset to use
487 */
488 preset: TransitionPreset;
489
490 /**
491 * Override duration from preset syntax (e.g., "fade.500")
492 */
493 duration?: number;
494
495 /**
496 * Override delay from preset syntax (e.g., "fade.500.100")
497 */
498 delay?: number;
499};
500
501/**
502 * Animation preset for CSS keyframe animations
503 */
504export type AnimationPreset = {
505 /**
506 * Array of keyframes for the animation
507 */
508 keyframes: Keyframe[];
509
510 /**
511 * Duration in milliseconds (default: varies by preset)
512 */
513 duration: number;
514
515 /**
516 * Number of iterations (use Infinity for infinite)
517 */
518 iterations: number;
519
520 /**
521 * CSS timing function (default: "ease")
522 */
523 timing: string;
524};
525
526/**
527 * Options for configuring a view transition
528 */
529export type ViewTransitionOptions = {
530 /**
531 * Named view transition for specific element(s)
532 * Maps to view-transition-name CSS property
533 */
534 name?: string;
535
536 /**
537 * Elements to apply named transitions to
538 * Each element will get a unique view-transition-name
539 */
540 elements?: HTMLElement[];
541
542 /**
543 * Skip transition if prefers-reduced-motion is enabled
544 * @default true
545 */
546 respectReducedMotion?: boolean;
547
548 /**
549 * Force CSS fallback even if View Transitions API is supported
550 * Useful for testing or debugging
551 * @default false
552 */
553 forceFallback?: boolean;
554};
555
556/**
557 * Error source categories for identifying where errors occurred
558 */
559export type ErrorSource = "evaluator" | "binding" | "effect" | "http" | "plugin" | "lifecycle" | "charge" | "user";
560
561/**
562 * Error severity level
563 *
564 * - `warn`: Non-critical issues that don't prevent operation (e.g., deprecated usage, missing optional features)
565 * - `error`: Recoverable errors that prevent specific operations (e.g., failed evaluations, missing elements)
566 * - `fatal`: Unrecoverable errors that should halt execution (e.g., critical initialization failures)
567 */
568export type ErrorLevel = "warn" | "error" | "fatal";
569
570/**
571 * Context information for error reporting
572 */
573export type ErrorContext = {
574 /** Error source category */
575 source: ErrorSource;
576 /** Error severity level (defaults to "error") */
577 level?: ErrorLevel;
578 /** DOM element where error occurred */
579 element?: HTMLElement;
580 /** Directive name (e.g., "data-volt-text", "data-volt-on-click") */
581 directive?: string;
582 /** Expression that failed */
583 expression?: string;
584 /** Plugin name (for plugin errors) */
585 pluginName?: string;
586 /** HTTP method and URL (for HTTP errors) */
587 httpMethod?: string;
588 httpUrl?: string;
589 httpStatus?: number;
590 /** Lifecycle hook name (for lifecycle errors) */
591 hookName?: string;
592 /** Additional custom context */
593 [key: string]: unknown;
594};
595
596/**
597 * Error handler function signature
598 */
599export type ErrorHandler = (error: VoltError) => void;