a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Core error handling and reporting system for VoltX.js
3 *
4 * Provides centralized error boundary with rich contextual information
5 * for debugging directives, expressions, effects, and HTTP operations.
6 *
7 * @module core/error
8 */
9import type { ErrorContext, ErrorHandler, ErrorLevel, ErrorSource } from "$types/volt";
10
11/**
12 * Base error class with VoltX context
13 *
14 * Wraps original errors with rich debugging information including ource, element, directive, and expression details.
15 */
16export class VoltError extends Error {
17 /** Error source category */
18 public readonly source: ErrorSource;
19 /** Error severity level */
20 public readonly level: ErrorLevel;
21 /** DOM element where error occurred */
22 public readonly element?: HTMLElement;
23 /** Directive name */
24 public readonly directive?: string;
25 /** Expression that failed */
26 public readonly expression?: string;
27 /** Original error */
28 public readonly cause: Error;
29 /** When error occurred */
30 public readonly timestamp: number;
31 /** Full error context */
32 public readonly context: ErrorContext;
33 /** Whether propagation was stopped */
34 private _stopped: boolean = false;
35
36 constructor(cause: Error, context: ErrorContext) {
37 const message = VoltError.buildMessage(cause, context);
38 super(message);
39 this.name = "VoltError";
40 this.cause = cause;
41 this.source = context.source;
42 this.level = context.level ?? "error";
43 this.element = context.element;
44 this.directive = context.directive;
45 this.expression = context.expression;
46 this.context = context;
47 this.timestamp = Date.now();
48
49 // V8-specific feature
50 // See: https://github.com/microsoft/TypeScript/issues/3926
51 if ((Error as any).captureStackTrace) {
52 (Error as any).captureStackTrace(this, this.constructor);
53 }
54 }
55
56 /**
57 * Stop propagation to subsequent error handlers
58 */
59 public stopPropagation(): void {
60 this._stopped = true;
61 }
62
63 /**
64 * Check if propagation was stopped
65 */
66 public get stopped(): boolean {
67 return this._stopped;
68 }
69
70 private static buildMessage(cause: Error, context: ErrorContext): string {
71 const parts: string[] = [];
72 const level = context.level ?? "error";
73
74 parts.push(`[${level.toUpperCase()}] [${context.source}] ${cause.message}`);
75
76 if (context.directive) {
77 parts.push(`Directive: ${context.directive}`);
78 }
79
80 if (context.expression) {
81 const truncated = context.expression.length > 100 ? `${context.expression.slice(0, 100)}...` : context.expression;
82 parts.push(`Expression: ${truncated}`);
83 }
84
85 if (context.pluginName) {
86 parts.push(`Plugin: ${context.pluginName}`);
87 }
88
89 if (context.httpMethod && context.httpUrl) {
90 parts.push(`HTTP: ${context.httpMethod} ${context.httpUrl}`);
91 if (context.httpStatus) {
92 parts.push(`Status: ${context.httpStatus}`);
93 }
94 }
95
96 if (context.hookName) {
97 parts.push(`Hook: ${context.hookName}`);
98 }
99
100 if (context.element) {
101 const tag = context.element.tagName.toLowerCase();
102 const id = context.element.id ? `#${context.element.id}` : "";
103 const cls = context.element.className ? `.${context.element.className.split(" ").join(".")}` : "";
104 parts.push(`Element: <${tag}${id}${cls}>`);
105 }
106
107 return parts.join(" | ");
108 }
109
110 /**
111 * Serialize error for logging/reporting
112 */
113 public toJSON(): Record<string, unknown> {
114 return {
115 name: this.name,
116 message: this.message,
117 source: this.source,
118 level: this.level,
119 directive: this.directive,
120 expression: this.expression,
121 timestamp: this.timestamp,
122 context: this.context,
123 cause: { name: this.cause.name, message: this.cause.message, stack: this.cause.stack },
124 stack: this.stack,
125 };
126 }
127}
128
129/**
130 * Error during expression evaluation
131 *
132 * Thrown when evaluating expressions in directives like data-volt-text, data-volt-if, or any other binding that uses the expression evaluator.
133 */
134export class EvaluatorError extends VoltError {
135 constructor(cause: Error, context: ErrorContext) {
136 super(cause, { ...context, source: "evaluator" });
137 this.name = "EvaluatorError";
138 }
139}
140
141/**
142 * Error during directive binding
143 *
144 * Thrown when setting up or executing DOM bindings like data-volt-text, data-volt-class, data-volt-model, etc.
145 */
146export class BindingError extends VoltError {
147 constructor(cause: Error, context: ErrorContext) {
148 super(cause, { ...context, source: "binding" });
149 this.name = "BindingError";
150 }
151}
152
153/**
154 * Error during effect execution
155 *
156 * Thrown when effects, computed signals, or async effects fail during execution or cleanup.
157 */
158export class EffectError extends VoltError {
159 constructor(cause: Error, context: ErrorContext) {
160 super(cause, { ...context, source: "effect" });
161 this.name = "EffectError";
162 }
163}
164
165/**
166 * Error during HTTP operations
167 *
168 * Thrown when HTTP directives (data-volt-get, data-volt-post, etc.) encounter network errors, parsing failures, or swap strategy issues.
169 */
170export class HttpError extends VoltError {
171 constructor(cause: Error, context: ErrorContext) {
172 super(cause, { ...context, source: "http" });
173 this.name = "HttpError";
174 }
175}
176
177/**
178 * Error in plugin execution
179 *
180 * Thrown when custom plugins registered via registerPlugin fail during initialization or execution.
181 */
182export class PluginError extends VoltError {
183 constructor(cause: Error, context: ErrorContext) {
184 super(cause, { ...context, source: "plugin" });
185 this.name = "PluginError";
186 }
187}
188
189/**
190 * Error in lifecycle hooks
191 *
192 * Thrown when lifecycle hooks (beforeMount, afterMount, onMount, etc.) fail during execution.
193 */
194export class LifecycleError extends VoltError {
195 constructor(cause: Error, context: ErrorContext) {
196 super(cause, { ...context, source: "lifecycle" });
197 this.name = "LifecycleError";
198 }
199}
200
201/**
202 * Error during charge/initialization
203 *
204 * Thrown when charge() encounters errors during auto-discovery and mounting of [data-volt] elements, or when parsing data-volt-state.
205 */
206export class ChargeError extends VoltError {
207 constructor(cause: Error, context: ErrorContext) {
208 super(cause, { ...context, source: "charge" });
209 this.name = "ChargeError";
210 }
211}
212
213/**
214 * User-triggered error
215 *
216 * Errors explicitly reported by user code via the report() function
217 * with source: "user".
218 */
219export class UserError extends VoltError {
220 constructor(cause: Error, context: ErrorContext) {
221 super(cause, { ...context, source: "user" });
222 this.name = "UserError";
223 }
224}
225
226/**
227 * Global error handler registry
228 */
229let errorHandlers: ErrorHandler[] = [];
230
231/**
232 * Register an error handler
233 *
234 * Multiple handlers can be registered and will be called in registration order.
235 * Handlers can call `error.stopPropagation()` to prevent subsequent handlers from being called.
236 *
237 * @param handler - Error handler function
238 * @returns Cleanup function to unregister the handler
239 *
240 * @example
241 * ```ts
242 * const cleanup = onError((error) => {
243 * console.log('Error source:', error.source);
244 * console.log('Element:', error.element);
245 * console.log('Expression:', error.expression);
246 *
247 * // Stop other handlers from running
248 * if (error.source === "http") {
249 * error.stopPropagation();
250 * }
251 * });
252 *
253 * // Later: cleanup()
254 * ```
255 */
256export function onError(handler: ErrorHandler): () => void {
257 errorHandlers.push(handler);
258 return () => {
259 errorHandlers = errorHandlers.filter((h) => h !== handler);
260 };
261}
262
263/**
264 * Clear all registered error handlers
265 *
266 * Useful for testing or when you want to reset error handling state.
267 *
268 * @example
269 * ```ts
270 * clearErrorHandlers();
271 * ```
272 */
273export function clearErrorHandlers(): void {
274 errorHandlers = [];
275}
276
277/**
278 * Report an error through the centralized error boundary
279 *
280 * This function is used both internally by VoltX and externally by user code.
281 * All errors flow through this unified system.
282 *
283 * If no error handlers are registered, errors are logged to console as fallback.
284 * Once handlers are registered, console logging is disabled.
285 *
286 * Error levels determine console output and behavior:
287 * - warn: Non-critical issues logged with console.warn
288 * - error: Recoverable errors logged with console.error (default)
289 * - fatal: Unrecoverable errors logged with console.error and thrown to halt execution
290 *
291 * @param error - Error to report (can be Error, unknown, or string)
292 * @param context - Error context with source and additional details
293 *
294 * @example
295 * ```ts
296 * // Warning for non-critical issues
297 * report(err, {
298 * source: "binding",
299 * level: "warn",
300 * directive: "data-volt-deprecated"
301 * });
302 *
303 * // Error for recoverable issues (default)
304 * report(err, {
305 * source: "evaluator",
306 * level: "error",
307 * directive: "data-volt-text",
308 * expression: expression
309 * });
310 *
311 * // Fatal error that halts execution
312 * report(err, {
313 * source: "charge",
314 * level: "fatal",
315 * directive: "data-volt-state"
316 * });
317 * ```
318 */
319export function report(error: unknown, context: ErrorContext): void {
320 const errorObj = error instanceof Error ? error : new Error(String(error));
321
322 const voltError = createErrorBySource(errorObj, context);
323
324 if (errorHandlers.length === 0) {
325 const logFn = voltError.level === "warn" ? console.warn : console.error;
326
327 logFn(voltError.message);
328 logFn("Caused by:", voltError.cause);
329 if (voltError.element) {
330 logFn("Element:", voltError.element);
331 }
332
333 if (voltError.level === "fatal") {
334 throw voltError;
335 }
336 return;
337 }
338
339 for (const handler of errorHandlers) {
340 try {
341 handler(voltError);
342 if (voltError.stopped) {
343 break;
344 }
345 } catch (handlerError) {
346 console.error("Error in error handler:", handlerError);
347 }
348 }
349
350 if (voltError.level === "fatal") {
351 throw voltError;
352 }
353}
354
355/**
356 * Create the appropriate error type based on the source
357 */
358function createErrorBySource(cause: Error, context: ErrorContext): VoltError {
359 switch (context.source) {
360 case "evaluator": {
361 return new EvaluatorError(cause, context);
362 }
363 case "binding": {
364 return new BindingError(cause, context);
365 }
366 case "effect": {
367 return new EffectError(cause, context);
368 }
369 case "http": {
370 return new HttpError(cause, context);
371 }
372 case "plugin": {
373 return new PluginError(cause, context);
374 }
375 case "lifecycle": {
376 return new LifecycleError(cause, context);
377 }
378 case "charge": {
379 return new ChargeError(cause, context);
380 }
381 case "user": {
382 return new UserError(cause, context);
383 }
384 default: {
385 return new VoltError(cause, context);
386 }
387 }
388}
389
390/**
391 * Get count of registered error handlers
392 *
393 * Useful for testing and debugging error handling setup.
394 *
395 * @returns Number of registered error handlers
396 */
397export function getErrorHandlerCount(): number {
398 return errorHandlers.length;
399}