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;