From e0b932e830ac716418df0870bf1a8c72c308922b Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:52:05 +0700 Subject: [PATCH 1/2] feat: add frontend console logging support --- CHANGELOG.md | 11 +++ Cargo.lock | 5 +- Cargo.toml | 6 +- README.md | 43 +++++++-- src/frontend.rs | 244 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 6 ++ 6 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 src/frontend.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 945b6d7..c21a1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.4.0] - 2025-09-15 +### Added +- Optional `frontend` feature: + - Converts [`AppError`] and [`ErrorResponse`] into `wasm_bindgen::JsValue` for browser contexts. + - Logs structured errors to the browser console via `console.error`. +- `BrowserConsoleError` and `BrowserConsoleExt` API for WASM front-ends. + +### Documentation +- Documented browser/WASM support and console logging workflow in the README and crate docs. + ## [0.3.5] - 2025-09-12 ### Added - Conversion from `teloxide_core::RequestError` into `AppError` (feature `teloxide`). @@ -111,4 +121,5 @@ All notable changes to this project will be documented in this file. [0.3.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.3.0 [0.2.1]: https://github.com/RAprogramm/masterror/releases/tag/v0.2.1 [0.2.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.2.0 +[0.4.0]: https://github.com/RAprogramm/masterror/releases/tag/v0.4.0 diff --git a/Cargo.lock b/Cargo.lock index f7c890c..72bb530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1491,15 +1491,17 @@ dependencies = [ [[package]] name = "masterror" -version = "0.3.5" +version = "0.4.0" dependencies = [ "actix-web", "axum", "config", "http 1.3.1", + "js-sys", "redis", "reqwest", "serde", + "serde-wasm-bindgen", "serde_json", "sqlx", "telegram-webapp-sdk", @@ -1509,6 +1511,7 @@ dependencies = [ "tracing", "utoipa", "validator", + "wasm-bindgen", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 92ec6b6..65a33e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.3.5" +version = "0.4.0" rust-version = "1.89" edition = "2024" description = "Application error types and response mapping" @@ -26,6 +26,7 @@ tokio = ["dep:tokio"] reqwest = ["dep:reqwest"] teloxide = ["dep:teloxide-core"] telegram-webapp-sdk = ["dep:telegram-webapp-sdk"] +frontend = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde-wasm-bindgen"] turnkey = [] openapi = ["dep:utoipa"] @@ -58,6 +59,9 @@ tokio = { version = "1", optional = true, features = ["time"] } reqwest = { version = "0.12", optional = true, default-features = false } teloxide-core = { version = "0.13", optional = true, default-features = false } telegram-webapp-sdk = { version = "0.1", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +js-sys = { version = "0.3", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } [dev-dependencies] serde_json = "1" diff --git a/README.md b/README.md index 70f8525..1204bcc 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,15 @@ Stable categories, conservative HTTP mapping, no `unsafe`. ~~~toml [dependencies] -masterror = { version = "0.3", default-features = false } +masterror = { version = "0.4", default-features = false } # or with features: -# masterror = { version = "0.3", features = [ +# masterror = { version = "0.4", features = [ # "axum", "actix", "serde_json", "openapi", # "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide" # ] } ~~~ +*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* *Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* --- @@ -53,10 +54,10 @@ masterror = { version = "0.3", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.3", default-features = false } +masterror = { version = "0.4", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.3", features = [ +# masterror = { version = "0.4", features = [ # "axum", "actix", "serde_json", "openapi", # "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide" # ] } @@ -160,12 +161,37 @@ async fn payload() -> impl Responder { ~~~toml [dependencies] -masterror = { version = "0.3", features = ["openapi", "serde_json"] } +masterror = { version = "0.4", features = ["openapi", "serde_json"] } utoipa = "5" ~~~ +
+ Browser (WASM) + +~~~rust +// features = ["frontend"] +use masterror::{AppError, AppErrorKind}; +use masterror::frontend::{BrowserConsoleError, BrowserConsoleExt}; + +fn report() -> Result<(), BrowserConsoleError> { + let err = AppError::bad_request("missing field"); + let payload = err.to_js_value()?; + assert!(payload.is_object()); + + #[cfg(target_arch = "wasm32")] + err.log_to_browser_console()?; + + Ok(()) +} +~~~ + +- On non-WASM targets `log_to_browser_console` returns + `BrowserConsoleError::UnsupportedTarget`. + +
+
Feature flags @@ -174,6 +200,7 @@ utoipa = "5" - `openapi` — utoipa schema - `serde_json` — JSON details - `sqlx`, `redis`, `reqwest`, `validator`, `config`, `tokio`, `multipart`, `teloxide`, `telegram-webapp-sdk` +- `frontend` — convert errors into `JsValue` and log via `console.error` (WASM) - `turnkey` — domain taxonomy and conversions for Turnkey errors
@@ -201,13 +228,13 @@ utoipa = "5" Minimal core: ~~~toml -masterror = { version = "0.3", default-features = false } +masterror = { version = "0.4", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.3", features = [ +masterror = { version = "0.4", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -216,7 +243,7 @@ masterror = { version = "0.3", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.3", features = [ +masterror = { version = "0.4", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/frontend.rs b/src/frontend.rs new file mode 100644 index 0000000..1083b18 --- /dev/null +++ b/src/frontend.rs @@ -0,0 +1,244 @@ +//! Browser/WASM helpers for converting application errors into JavaScript +//! values. +//! +//! This module is available when the `frontend` feature is enabled. It provides +//! helpers to serialize [`AppError`] and [`ErrorResponse`] into +//! [`wasm_bindgen::JsValue`] and optionally emit structured logs via +//! `console.error` when running inside a browser. +//! +//! # Examples +//! +//! ```rust +//! # #[cfg(feature = "frontend")] +//! # { +//! use masterror::{ +//! AppError, +//! frontend::{BrowserConsoleError, BrowserConsoleExt} +//! }; +//! +//! let err = AppError::bad_request("invalid payload"); +//! +//! #[cfg(target_arch = "wasm32")] +//! { +//! let js = err.to_js_value().expect("js value"); +//! assert!(js.is_object()); +//! err.log_to_browser_console().expect("console error log"); +//! } +//! +//! #[cfg(not(target_arch = "wasm32"))] +//! assert!(matches!( +//! err.to_js_value(), +//! Err(BrowserConsoleError::UnsupportedTarget) +//! )); +//! # } +//! ``` + +#[cfg(target_arch = "wasm32")] +use js_sys::{Function, Reflect}; +#[cfg(target_arch = "wasm32")] +use serde_wasm_bindgen::to_value; +use thiserror::Error; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use crate::{AppError, ErrorResponse}; + +/// Error returned when emitting to the browser console fails or is unsupported. +#[derive(Debug, Error, PartialEq, Eq)] +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub enum BrowserConsoleError { + /// Failed to serialize the payload into [`JsValue`]. + #[error("failed to serialize payload for browser console: {message}")] + Serialization { + /// Human-readable description of the serialization failure. + message: String + }, + /// The global `console` object is unavailable or could not be accessed. + #[error("browser console object is not available: {message}")] + ConsoleUnavailable { + /// Additional context explaining the failure. + message: String + }, + /// The `console.error` function is missing or not accessible. + #[error("failed to access browser console `error`: {message}")] + ConsoleErrorUnavailable { + /// Additional context explaining the failure. + message: String + }, + /// The retrieved `console.error` value is not callable. + #[error("browser console `error` method is not callable")] + ConsoleMethodNotCallable, + /// Invoking `console.error` returned an error. + #[error("failed to invoke browser console `error`: {message}")] + ConsoleInvocation { + /// Textual representation of the JavaScript exception. + message: String + }, + /// Logging is not supported on the current compilation target. + #[error("browser console logging is not supported on this target")] + UnsupportedTarget +} + +/// Extensions for serializing errors to JavaScript and logging to the browser +/// console. +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub trait BrowserConsoleExt { + /// Convert the error into a [`JsValue`] suitable for passing to JavaScript. + fn to_js_value(&self) -> Result; + + /// Emit the error as a structured payload via `console.error`. + /// + /// On non-WASM targets this returns + /// [`BrowserConsoleError::UnsupportedTarget`]. + fn log_to_browser_console(&self) -> Result<(), BrowserConsoleError> { + let payload = self.to_js_value()?; + log_js_value(&payload) + } +} + +impl BrowserConsoleExt for ErrorResponse { + fn to_js_value(&self) -> Result { + #[cfg(target_arch = "wasm32")] + { + to_value(self).map_err(|err| BrowserConsoleError::Serialization { + message: err.to_string() + }) + } + + #[cfg(not(target_arch = "wasm32"))] + { + Err(BrowserConsoleError::UnsupportedTarget) + } + } +} + +impl BrowserConsoleExt for AppError { + fn to_js_value(&self) -> Result { + #[cfg(target_arch = "wasm32")] + { + let response: ErrorResponse = self.into(); + response.to_js_value() + } + + #[cfg(not(target_arch = "wasm32"))] + { + Err(BrowserConsoleError::UnsupportedTarget) + } + } +} + +#[cfg(target_arch = "wasm32")] +fn log_js_value(value: &JsValue) -> Result<(), BrowserConsoleError> { + let global = js_sys::global(); + let console = Reflect::get(&global, &JsValue::from_str("console")).map_err(|err| { + BrowserConsoleError::ConsoleUnavailable { + message: format_js_value(&err) + } + })?; + + if console.is_undefined() || console.is_null() { + return Err(BrowserConsoleError::ConsoleUnavailable { + message: "console is undefined".into() + }); + } + + let error_fn = Reflect::get(&console, &JsValue::from_str("error")).map_err(|err| { + BrowserConsoleError::ConsoleErrorUnavailable { + message: format_js_value(&err) + } + })?; + + if error_fn.is_undefined() || error_fn.is_null() { + return Err(BrowserConsoleError::ConsoleErrorUnavailable { + message: "console.error is undefined".into() + }); + } + + let func = error_fn + .dyn_into::() + .map_err(|_| BrowserConsoleError::ConsoleMethodNotCallable)?; + + func.call1(&console, value) + .map_err(|err| BrowserConsoleError::ConsoleInvocation { + message: format_js_value(&err) + })?; + + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn log_js_value(_value: &JsValue) -> Result<(), BrowserConsoleError> { + Err(BrowserConsoleError::UnsupportedTarget) +} + +#[cfg(target_arch = "wasm32")] +fn format_js_value(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AppCode; + + #[cfg(not(target_arch = "wasm32"))] + mod native { + use super::*; + + #[test] + fn to_js_value_is_unsupported_on_native_targets() { + let response = + ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); + assert!(matches!( + response.to_js_value(), + Err(BrowserConsoleError::UnsupportedTarget) + )); + + let err = AppError::conflict("already exists"); + assert!(matches!( + err.to_js_value(), + Err(BrowserConsoleError::UnsupportedTarget) + )); + } + + #[test] + fn console_logging_returns_unsupported_on_native_targets() { + let err = AppError::internal("boom"); + let result = err.log_to_browser_console(); + assert!(matches!( + result, + Err(BrowserConsoleError::UnsupportedTarget) + )); + } + } + + #[cfg(target_arch = "wasm32")] + mod wasm { + use serde_wasm_bindgen::from_value; + + use super::*; + use crate::AppErrorKind; + + #[test] + fn error_response_to_js_value_roundtrip() { + let response = + ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); + let js = response.to_js_value().expect("serialize"); + let decoded: ErrorResponse = from_value(js).expect("decode"); + assert_eq!(decoded.status, 404); + assert_eq!(decoded.code, AppCode::NotFound); + assert_eq!(decoded.message, "missing user"); + } + + #[test] + fn app_error_to_js_value_roundtrip() { + let err = AppError::conflict("already exists"); + let js = err.to_js_value().expect("serialize"); + let decoded: ErrorResponse = from_value(js).expect("decode"); + assert_eq!(decoded.code, AppCode::Conflict); + assert_eq!(decoded.message, "already exists"); + assert_eq!(decoded.status, AppErrorKind::Conflict.http_status()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 7139b39..93eadf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,8 @@ //! - `telegram-webapp-sdk` — //! `From` //! mapping +//! - `frontend` — convert errors into `wasm_bindgen::JsValue` and emit +//! `console.error` logs in WASM/browser contexts //! - `serde_json` — support for structured JSON details in [`ErrorResponse`]; //! also pulled transitively by `axum` //! - `multipart` — compatibility flag for Axum multipart @@ -186,6 +188,10 @@ mod convert; mod kind; mod response; +#[cfg(feature = "frontend")] +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub mod frontend; + #[cfg(feature = "turnkey")] #[cfg_attr(docsrs, doc(cfg(feature = "turnkey")))] pub mod turnkey; From 778345264f995308d13961f7c9c752ad3c42708a Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:01:19 +0700 Subject: [PATCH 2/2] Use AppResult alias in docs --- src/convert.rs | 12 ++++++------ src/convert/actix.rs | 2 +- src/convert/validator.rs | 4 ++-- src/lib.rs | 2 +- src/prelude.rs | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/convert.rs b/src/convert.rs index eb0d6d3..cd9258b 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -46,9 +46,9 @@ //! ```rust //! use std::io::{self, ErrorKind}; //! -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppError, AppErrorKind, AppResult}; //! -//! fn open() -> Result<(), AppError> { +//! fn open() -> AppResult<()> { //! let _ = io::Error::new(ErrorKind::Other, "boom"); //! Err(io::Error::from(ErrorKind::Other).into()) //! } @@ -61,9 +61,9 @@ //! feature): //! //! ```rust -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppError, AppErrorKind, AppResult}; //! -//! fn validate(x: i32) -> Result<(), AppError> { +//! fn validate(x: i32) -> AppResult<()> { //! if x < 0 { //! return Err(String::from("must be non-negative").into()); //! } @@ -149,9 +149,9 @@ impl From for AppError { /// Prefer structured validation for complex DTOs, but this covers simple cases. /// /// ```rust -/// use masterror::{AppError, AppErrorKind}; +/// use masterror::{AppError, AppErrorKind, AppResult}; /// -/// fn check(name: &str) -> Result<(), AppError> { +/// fn check(name: &str) -> AppResult<()> { /// if name.is_empty() { /// return Err(String::from("name must not be empty").into()); /// } diff --git a/src/convert/actix.rs b/src/convert/actix.rs index a7868aa..eb1c7e1 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -5,7 +5,7 @@ //! //! ## What it does //! - Implements `actix_web::ResponseError` for [`AppError`]. -//! - This lets you `return Result<_, AppError>` from Actix handlers. +//! - This lets you `return AppResult<_>` from Actix handlers. //! - On error, Actix automatically builds an `HttpResponse` with the right //! status code and JSON body (when the `serde_json` feature is enabled). //! - Provides stable mapping from [`AppErrorKind`] to diff --git a/src/convert/validator.rs b/src/convert/validator.rs index 0022f0e..e5f7f47 100644 --- a/src/convert/validator.rs +++ b/src/convert/validator.rs @@ -19,7 +19,7 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppError, AppErrorKind, AppResult}; //! use validator::{Validate, ValidationError}; //! //! #[derive(Validate)] @@ -28,7 +28,7 @@ //! name: String, //! } //! -//! fn validate_payload(p: Payload) -> Result<(), AppError> { +//! fn validate_payload(p: Payload) -> AppResult<()> { //! p.validate()?; //! Ok(()) //! } diff --git a/src/lib.rs b/src/lib.rs index 93eadf2..1e0fa0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ //! - [`AppError`] — thin wrapper around a semantic error kind and optional //! message //! - [`AppErrorKind`] — stable internal taxonomy of application errors -//! - [`AppResult`] — convenience alias `Result` +//! - [`AppResult`] — convenience alias for returning [`AppError`] //! - [`ErrorResponse`] — stable wire-level JSON payload for HTTP APIs //! - [`AppCode`] — public, machine-readable error code for clients //! diff --git a/src/prelude.rs b/src/prelude.rs index 4824b6d..a1d85da 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -35,7 +35,7 @@ pub use crate::AppCode; pub use crate::AppError; /// High-level taxonomy of application errors (stable categories). pub use crate::AppErrorKind; -/// Convenience alias `Result` used in handlers/services. +/// Convenience alias for returning [`AppError`] from handlers/services. pub use crate::AppResult; /// Stable wire-level error payload for HTTP APIs. pub use crate::ErrorResponse;