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