Skip to main content

dioxus_js_bindgen/
lib.rs

1//! # dioxus-js-bindgen
2//!
3//! Zero-Build, Low-Annotation, RAII-Safe Rust <-> JS/TS FFI Binding Engine for Dioxus.
4
5pub use dioxus_js_bindgen_macro::bind_js;
6pub use serde;
7pub use serde_json;
8pub use tracing;
9
10pub mod watcher_guard;
11pub use watcher_guard::WatcherGuard;
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use dioxus::prelude::*;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18/// JS FFI 에러 타입
19#[derive(Debug, Error, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub enum JsError {
21    #[error("JavaScript Exception: {message}\nStack: {stack:?}")]
22    Exception {
23        message: String,
24        stack: Option<String>,
25    },
26    #[error("Transport Error: {0}")]
27    Transport(String),
28    #[error("Module Unavailable: '{0}' (Context may have been reset)")]
29    ModuleUnavailable(String),
30    #[error("Deserialization Error: {0}")]
31    Deserialization(String),
32}
33
34/// Query 비동기 RPC 응답 페이로드
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct RpcResponse<T> {
37    pub ok: bool,
38    pub data: Option<T>,
39    pub error: Option<String>,
40    pub stack: Option<String>,
41}
42
43impl<T> RpcResponse<T> {
44    pub fn as_error(&self) -> Option<&str> {
45        if !self.ok {
46            self.error.as_deref()
47        } else {
48            None
49        }
50    }
51
52    pub fn into_result(self) -> Result<T, JsError> {
53        if self.ok {
54            self.data.ok_or_else(|| JsError::Transport("Missing data in success response".into()))
55        } else {
56            Err(JsError::Exception {
57                message: self.error.unwrap_or_else(|| "Unknown JS error".into()),
58                stack: self.stack,
59            })
60        }
61    }
62}
63
64/// 브라우저에 주입된 JS 모듈 캐시와 Rust 측 Epoch를 무효화하여 모든 바인딩 모듈이 다음 호출 시 브라우저에 재평가/재등록되도록 합니다.
65///
66/// **계약 보장**: 이 함수는 모듈 번들의 평가 캐시와 Epoch를 무효화할 뿐이며, 현재 실행 중인 활성 `WatcherGuard`나
67/// 구독 이벤트 스트림의 생명주기를 전역으로 자동 중단하지는 않습니다. (개별 감시자 정리는 `WatcherGuard`의 Drop을 통해 수행됩니다.)
68pub fn clear_js_cache() {
69    internal::GLOBAL_EPOCH.fetch_add(1, Ordering::SeqCst);
70    let _ = std::panic::catch_unwind(|| {
71        let _ = dioxus::document::eval(
72            r#"
73            if (window.__DIOXUS_BINDGEN_MODULES__) {
74                window.__DIOXUS_BINDGEN_MODULES__ = {};
75            }
76            "#,
77        );
78    });
79}
80
81/// 하위 호환성을 위한 레거시 별칭 (대신 [`clear_js_cache`] 사용 권장)
82pub use clear_js_cache as reset_module_registry;
83
84/// Dioxus Signal을 자동으로 추적하여 의존성 변경 시 이전 감시자를 Drop하고 새 감시자를 시작하는 순수 리액티브 훅
85pub fn use_watcher<W: 'static>(mut factory: impl FnMut() -> Option<W> + 'static) {
86    let mut current_watcher = dioxus::prelude::use_signal(|| None::<W>);
87
88    dioxus::prelude::use_effect(move || {
89        let new_watcher = factory();
90        current_watcher.set(new_watcher);
91    });
92}
93
94#[doc(hidden)]
95pub mod internal {
96    use super::*;
97
98    pub static GLOBAL_EPOCH: AtomicU64 = AtomicU64::new(1);
99    static NEXT_SUB_ID: AtomicU64 = AtomicU64::new(1);
100
101    #[inline]
102    pub fn current_epoch() -> u64 {
103        GLOBAL_EPOCH.load(Ordering::Acquire)
104    }
105
106    #[inline]
107    pub fn next_subscription_id() -> u64 {
108        NEXT_SUB_ID.fetch_add(1, Ordering::Relaxed)
109    }
110
111    pub fn dispatch_cleanup(sub_id: u64) {
112        if dioxus::core::Runtime::try_current().is_none() {
113            return;
114        }
115        let _ = std::panic::catch_unwind(|| {
116            let _ = dioxus::document::eval(&format!(
117                r#"
118                (function() {{
119                    const c = window.__DIOXUS_WATCHERS?.get({sub_id});
120                    if (c) {{
121                        try {{ c(); }} catch(e) {{ console.error("[Watcher Cleanup Error]:", e); }}
122                        window.__DIOXUS_WATCHERS.delete({sub_id});
123                    }}
124                }})();
125                "#
126            ));
127        });
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_rpc_response_success() {
137        let resp = RpcResponse {
138            ok: true,
139            data: Some(42),
140            error: None,
141            stack: None,
142        };
143        assert_eq!(resp.into_result().unwrap(), 42);
144    }
145
146    #[test]
147    fn test_rpc_response_error() {
148        let resp: RpcResponse<i32> = RpcResponse {
149            ok: false,
150            data: None,
151            error: Some("Element not found".into()),
152            stack: Some("stack trace".into()),
153        };
154        match resp.into_result() {
155            Err(JsError::Exception { message, stack }) => {
156                assert_eq!(message, "Element not found");
157                assert_eq!(stack, Some("stack trace".into()));
158            }
159            _ => panic!("Expected JsError::Exception"),
160        }
161    }
162
163    #[test]
164    fn test_epoch_increment() {
165        let initial = internal::current_epoch();
166        clear_js_cache();
167        assert_eq!(internal::current_epoch(), initial + 1);
168
169        // Verify backward-compatible alias
170        reset_module_registry();
171        assert_eq!(internal::current_epoch(), initial + 2);
172    }
173
174    #[test]
175    fn test_subscription_id_increment() {
176        let id1 = internal::next_subscription_id();
177        let id2 = internal::next_subscription_id();
178        assert!(id2 > id1);
179    }
180
181    #[test]
182    fn test_js_error_display() {
183        let err = JsError::ModuleUnavailable("module_123".into());
184        assert!(err.to_string().contains("module_123"));
185
186        let err2 = JsError::Transport("failed to connect".into());
187        assert!(err2.to_string().contains("failed to connect"));
188    }
189
190    #[test]
191    fn test_rpc_as_error() {
192        let resp: RpcResponse<()> = RpcResponse {
193            ok: false,
194            data: None,
195            error: Some("MODULE_NOT_FOUND".into()),
196            stack: None,
197        };
198        assert_eq!(resp.as_error(), Some("MODULE_NOT_FOUND"));
199
200        let ok_resp: RpcResponse<i32> = RpcResponse {
201            ok: true,
202            data: Some(10),
203            error: None,
204            stack: None,
205        };
206        assert_eq!(ok_resp.as_error(), None);
207    }
208
209    #[test]
210    fn test_watcher_guard_lifecycle() {
211        let guard = WatcherGuard::new("watch_resize", 42, None);
212        assert_eq!(guard.name(), "watch_resize");
213        assert_eq!(guard.subscription_id(), 42);
214        let debug_str = format!("{:?}", guard);
215        assert!(debug_str.contains("watch_resize"));
216        assert!(debug_str.contains("42"));
217        // Drop test outside runtime - must not panic
218        drop(guard);
219    }
220}