Skip to main content

pocopine_core/
client_module.rs

1//! Low-level access to CLI-bundled `.client.ts` modules.
2//!
3//! The CLI writes default exports into `window.__pp_client_modules`.
4//! The public author-facing API is `#[pocopine::client_module]`;
5//! this wrapper keeps generated facades away from raw `Reflect`,
6//! `Promise`, and callback-lifetime plumbing.
7
8use std::error::Error;
9use std::fmt;
10
11use serde::de::DeserializeOwned;
12
13use crate::ScopeId;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ClientModuleError {
17    message: String,
18}
19
20impl ClientModuleError {
21    pub fn new(message: impl Into<String>) -> Self {
22        Self {
23            message: message.into(),
24        }
25    }
26
27    pub fn message(&self) -> &str {
28        &self.message
29    }
30}
31
32impl fmt::Display for ClientModuleError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(&self.message)
35    }
36}
37
38impl Error for ClientModuleError {}
39
40#[derive(Clone, Debug)]
41pub struct ClientModule {
42    name: String,
43    #[cfg(target_arch = "wasm32")]
44    value: wasm_bindgen::JsValue,
45}
46
47impl ClientModule {
48    pub fn required(name: impl Into<String>) -> Result<Self, ClientModuleError> {
49        let name = name.into();
50        #[cfg(not(target_arch = "wasm32"))]
51        {
52            Err(ClientModuleError::new(format!(
53                "client module `{name}` is only available in the browser"
54            )))
55        }
56        #[cfg(target_arch = "wasm32")]
57        {
58            Self::optional(&name)?.ok_or_else(|| {
59                ClientModuleError::new(format!("client module `{name}` is not registered"))
60            })
61        }
62    }
63
64    pub fn optional(name: impl Into<String>) -> Result<Option<Self>, ClientModuleError> {
65        let name = name.into();
66        optional_impl(name)
67    }
68
69    pub fn name(&self) -> &str {
70        &self.name
71    }
72
73    pub async fn call_async<T>(&self, method: impl AsRef<str>) -> Result<T, ClientModuleError>
74    where
75        T: DeserializeOwned,
76    {
77        call_async_impl(self, method.as_ref()).await
78    }
79
80    pub fn subscribe<T>(
81        &self,
82        scope: ScopeId,
83        method: impl AsRef<str>,
84        handler: impl FnMut(Result<T, ClientModuleError>) + 'static,
85    ) -> Result<(), ClientModuleError>
86    where
87        T: DeserializeOwned + 'static,
88    {
89        subscribe_impl(self, scope, method.as_ref(), handler)
90    }
91}
92
93#[cfg(target_arch = "wasm32")]
94mod wasm {
95    use std::cell::RefCell;
96    use std::collections::HashMap;
97    use std::rc::Rc;
98
99    use js_sys::{Function, Promise, Reflect};
100    use wasm_bindgen::closure::Closure;
101    use wasm_bindgen::{JsCast, JsValue};
102    use wasm_bindgen_futures::{spawn_local, JsFuture};
103
104    use super::{ClientModule, ClientModuleError, DeserializeOwned, ScopeId};
105
106    const REGISTRY_NAME: &str = "__pp_client_modules";
107
108    thread_local! {
109        static SUBSCRIPTIONS: RefCell<HashMap<ScopeId, Vec<ClientModuleSubscription>>> =
110            RefCell::new(HashMap::new());
111    }
112
113    struct ClientModuleSubscription {
114        unsubscribe: Option<Function>,
115        _callback: Closure<dyn FnMut(JsValue)>,
116    }
117
118    impl Drop for ClientModuleSubscription {
119        fn drop(&mut self) {
120            if let Some(unsubscribe) = self.unsubscribe.take() {
121                let _ = unsubscribe.call0(&JsValue::NULL);
122            }
123        }
124    }
125
126    pub(super) fn optional_impl(name: String) -> Result<Option<ClientModule>, ClientModuleError> {
127        let global = js_sys::global();
128        let registry =
129            Reflect::get(&global, &JsValue::from_str(REGISTRY_NAME)).map_err(|value| {
130                ClientModuleError::new(js_value_message(value, "read client modules"))
131            })?;
132        if registry.is_null() || registry.is_undefined() {
133            return Ok(None);
134        }
135
136        let value = Reflect::get(&registry, &JsValue::from_str(&name)).map_err(|value| {
137            ClientModuleError::new(format!(
138                "client module `{name}` could not be read: {}",
139                js_value_message(value, "unknown JavaScript error")
140            ))
141        })?;
142        if value.is_null() || value.is_undefined() {
143            return Ok(None);
144        }
145
146        Ok(Some(ClientModule { name, value }))
147    }
148
149    pub(super) async fn call_async_impl<T>(
150        module: &ClientModule,
151        method: &str,
152    ) -> Result<T, ClientModuleError>
153    where
154        T: DeserializeOwned,
155    {
156        let promise = module
157            .call_raw(method)?
158            .dyn_into::<Promise>()
159            .map_err(|_| {
160                ClientModuleError::new(format!(
161                    "client module `{}.{method}` did not return a Promise",
162                    module.name
163                ))
164            })?;
165        let value = JsFuture::from(promise).await.map_err(|value| {
166            ClientModuleError::new(format!(
167                "client module `{}.{method}` failed: {}",
168                module.name,
169                js_value_message(value, "unknown JavaScript error")
170            ))
171        })?;
172        decode_value(&module.name, method, value)
173    }
174
175    pub(super) fn subscribe_impl<T>(
176        module: &ClientModule,
177        scope: ScopeId,
178        method: &str,
179        handler: impl FnMut(Result<T, ClientModuleError>) + 'static,
180    ) -> Result<(), ClientModuleError>
181    where
182        T: DeserializeOwned + 'static,
183    {
184        let method_fn = module.method(method)?;
185        let module_name = module.name.clone();
186        let method_name = method.to_string();
187        let handler = Rc::new(RefCell::new(handler));
188        let callback = Closure::<dyn FnMut(JsValue)>::new(move |value| {
189            let result = decode_value(&module_name, &method_name, value);
190            let handler = handler.clone();
191            spawn_local(async move {
192                handler.borrow_mut()(result);
193            });
194        });
195        let unsubscribe = method_fn
196            .call1(&module.value, callback.as_ref())
197            .map_err(|value| {
198                ClientModuleError::new(format!(
199                    "client module `{}.{method}` subscribe failed: {}",
200                    module.name,
201                    js_value_message(value, "unknown JavaScript error")
202                ))
203            })?
204            .dyn_into::<Function>()
205            .map_err(|_| {
206                ClientModuleError::new(format!(
207                    "client module `{}.{method}` did not return an unsubscribe function",
208                    module.name
209                ))
210            })?;
211
212        SUBSCRIPTIONS.with(|subscriptions| {
213            subscriptions
214                .borrow_mut()
215                .entry(scope)
216                .or_default()
217                .push(ClientModuleSubscription {
218                    unsubscribe: Some(unsubscribe),
219                    _callback: callback,
220                });
221        });
222        crate::on_scope_unmount_for(scope, move || {
223            SUBSCRIPTIONS.with(|subscriptions| {
224                subscriptions.borrow_mut().remove(&scope);
225            });
226        });
227
228        Ok(())
229    }
230
231    impl ClientModule {
232        fn call_raw(&self, method: &str) -> Result<JsValue, ClientModuleError> {
233            self.method(method)?.call0(&self.value).map_err(|value| {
234                ClientModuleError::new(format!(
235                    "client module `{}.{method}` failed: {}",
236                    self.name,
237                    js_value_message(value, "unknown JavaScript error")
238                ))
239            })
240        }
241
242        fn method(&self, method: &str) -> Result<Function, ClientModuleError> {
243            Reflect::get(&self.value, &JsValue::from_str(method))
244                .map_err(|value| {
245                    ClientModuleError::new(format!(
246                        "client module `{}.{method}` could not be read: {}",
247                        self.name,
248                        js_value_message(value, "unknown JavaScript error")
249                    ))
250                })?
251                .dyn_into::<Function>()
252                .map_err(|_| {
253                    ClientModuleError::new(format!(
254                        "client module `{}.{method}` is not a function",
255                        self.name
256                    ))
257                })
258        }
259    }
260
261    fn decode_value<T>(module: &str, method: &str, value: JsValue) -> Result<T, ClientModuleError>
262    where
263        T: DeserializeOwned,
264    {
265        serde_wasm_bindgen::from_value(value).map_err(|err| {
266            ClientModuleError::new(format!(
267                "client module `{module}.{method}` returned an invalid payload: {err}"
268            ))
269        })
270    }
271
272    fn js_value_message(value: JsValue, fallback: &str) -> String {
273        if let Some(message) = value.as_string() {
274            return message;
275        }
276        if let Ok(message) = Reflect::get(&value, &JsValue::from_str("message")) {
277            if let Some(message) = message.as_string() {
278                return message;
279            }
280        }
281        fallback.to_string()
282    }
283}
284
285#[cfg(target_arch = "wasm32")]
286use wasm::{call_async_impl, optional_impl, subscribe_impl};
287
288#[cfg(not(target_arch = "wasm32"))]
289fn optional_impl(name: String) -> Result<Option<ClientModule>, ClientModuleError> {
290    let _ = name;
291    Ok(None)
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295async fn call_async_impl<T>(module: &ClientModule, method: &str) -> Result<T, ClientModuleError>
296where
297    T: DeserializeOwned,
298{
299    Err(ClientModuleError::new(format!(
300        "client module `{}.{method}` is only available in the browser",
301        module.name
302    )))
303}
304
305#[cfg(not(target_arch = "wasm32"))]
306fn subscribe_impl<T>(
307    module: &ClientModule,
308    _scope: ScopeId,
309    method: &str,
310    _handler: impl FnMut(Result<T, ClientModuleError>) + 'static,
311) -> Result<(), ClientModuleError>
312where
313    T: DeserializeOwned + 'static,
314{
315    Err(ClientModuleError::new(format!(
316        "client module `{}.{method}` is only available in the browser",
317        module.name
318    )))
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn missing_client_module_message_names_the_module() {
327        let err = ClientModule::required("firebase").unwrap_err();
328        assert_eq!(
329            err.message(),
330            "client module `firebase` is only available in the browser"
331        );
332    }
333}