Skip to main content

eggfetch_node/
client.rs

1//! Eggfetch Client for Node.js.
2
3use napi_derive::napi;
4use std::ptr;
5
6use eggfetch_ffi::ErrorHandle;
7
8/// Pointer to the FFI client handle.
9///
10/// Stored as a raw pointer (not `usize`) so pointer provenance is preserved
11/// under Miri/provenance-strict tooling. The wrapper is `Send + Sync`
12/// because the underlying `ClientHandle` is `Send + Sync` per eggfetch-ffi
13/// documentation; lifetime safety for in-flight requests still relies on
14/// napi-rs holding a strong reference on `this` (see below).
15#[derive(Clone, Copy)]
16struct SendClientPtr(*mut eggfetch_ffi::ClientHandle);
17
18impl SendClientPtr {
19    /// Consume the wrapper and return the raw handle.
20    ///
21    /// Taking `self` by value keeps closure-capture analysis on the `Send`
22    /// wrapper itself: a direct `.0` field access inside `spawn_blocking`
23    /// would capture only the bare `*mut` field (which is not `Send`).
24    fn into_inner(self) -> *mut eggfetch_ffi::ClientHandle {
25        self.0
26    }
27}
28
29// SAFETY: the pointed-to `ClientHandle` is documented as `Send + Sync`, so
30// moving the pointer across threads (for `spawn_blocking`) is sound. The
31// pointee is freed only by `Drop for EggfetchClient`, which napi-rs
32// finalization keeps alive until in-flight request futures resolve.
33unsafe impl Send for SendClientPtr {}
34unsafe impl Sync for SendClientPtr {}
35
36/// HTTP client wrapping eggfetch-ffi.
37///
38/// The client pointer is stored as a [`SendClientPtr`] raw-pointer wrapper
39/// to satisfy napi's `Send` requirement for async futures while preserving
40/// pointer provenance (no `usize ↔ *mut` round-trip). The underlying
41/// `ClientHandle` is `Send + Sync` per eggfetch-ffi documentation.
42///
43/// # Lifetime safety for in-flight requests
44///
45/// Copying the raw pointer into a `'static` future is safe here because of
46/// how napi-rs (2.x) generates async class methods: before launching the
47/// future it creates a *strong* `napi_ref` (refcount 1) on `this`
48/// (`napi_create_reference`) and releases it only when the future resolves
49/// (see `napi-derive-backend` codegen, `NapiRefContainer`). A strong
50/// reference prevents garbage collection and finalization of the JS object,
51/// so `Drop for EggfetchClient` cannot free the FFI handle while any
52/// request future is outstanding. Do not replace this with a pattern that
53/// frees the handle independently of napi's reference tracking without
54/// adding an equivalent guard.
55///
56/// This remains an experimental prototype (see `lib.rs`): requests execute
57/// through the synchronous C ABI inside `spawn_blocking`.
58#[napi]
59pub struct EggfetchClient {
60    inner: SendClientPtr,
61}
62
63#[napi]
64impl EggfetchClient {
65    /// Create a new client with default settings.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the underlying FFI client allocation fails.
70    #[napi(constructor)]
71    pub fn new() -> napi::Result<Self> {
72        let inner = unsafe { eggfetch_ffi::eggfetch_client_new() };
73        if inner.is_null() {
74            return Err(napi::Error::from_reason(
75                "failed to create eggfetch client: allocation failed or runtime unavailable",
76            ));
77        }
78        Ok(Self {
79            inner: SendClientPtr(inner),
80        })
81    }
82
83    /// Send a GET request and return the response.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the request fails or the URL is invalid.
88    #[napi]
89    pub async fn get(&self, url: String) -> napi::Result<crate::EggfetchResponse> {
90        self.send_request("GET", &url, None).await
91    }
92
93    /// Send a POST request with optional body.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the request fails or the URL/body is invalid.
98    #[napi]
99    pub async fn post(
100        &self,
101        url: String,
102        body: Option<String>,
103    ) -> napi::Result<crate::EggfetchResponse> {
104        self.send_request("POST", &url, body.as_deref()).await
105    }
106
107    /// Send a PUT request with optional body.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the request fails or the URL/body is invalid.
112    #[napi]
113    pub async fn put(
114        &self,
115        url: String,
116        body: Option<String>,
117    ) -> napi::Result<crate::EggfetchResponse> {
118        self.send_request("PUT", &url, body.as_deref()).await
119    }
120
121    /// Send a PATCH request with optional body.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the request fails or the URL/body is invalid.
126    #[napi]
127    pub async fn patch(
128        &self,
129        url: String,
130        body: Option<String>,
131    ) -> napi::Result<crate::EggfetchResponse> {
132        self.send_request("PATCH", &url, body.as_deref()).await
133    }
134
135    /// Send a DELETE request.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the request fails or the URL is invalid.
140    #[napi]
141    pub async fn delete(&self, url: String) -> napi::Result<crate::EggfetchResponse> {
142        self.send_request("DELETE", &url, None).await
143    }
144
145    /// Send a HEAD request.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the request fails or the URL is invalid.
150    #[napi]
151    pub async fn head(&self, url: String) -> napi::Result<crate::EggfetchResponse> {
152        self.send_request("HEAD", &url, None).await
153    }
154
155    /// Send an OPTIONS request.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the request fails or the URL is invalid.
160    #[napi]
161    pub async fn options(&self, url: String) -> napi::Result<crate::EggfetchResponse> {
162        self.send_request("OPTIONS", &url, None).await
163    }
164
165    /// Send a request with a custom method.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the request fails or the method/URL is invalid.
170    #[napi]
171    pub async fn request(
172        &self,
173        method: String,
174        url: String,
175        body: Option<String>,
176    ) -> napi::Result<crate::EggfetchResponse> {
177        self.send_request(&method, &url, body.as_deref()).await
178    }
179}
180
181impl EggfetchClient {
182    fn send_request(
183        &self,
184        method: &str,
185        url: &str,
186        body: Option<&str>,
187    ) -> impl std::future::Future<Output = napi::Result<crate::EggfetchResponse>> + Send {
188        let client_ptr = self.inner;
189        let method = method.to_owned();
190        let url = url.to_owned();
191        let body = body.map(String::from);
192
193        async move {
194            let wrapper = client_ptr;
195            napi::bindgen_prelude::spawn_blocking(move || {
196                let client = wrapper.into_inner();
197                let method_c = std::ffi::CString::new(method)
198                    .map_err(|e| napi::Error::from_reason(format!("invalid method string: {e}")))?;
199                let url_c = std::ffi::CString::new(url)
200                    .map_err(|e| napi::Error::from_reason(format!("invalid url string: {e}")))?;
201
202                let req = unsafe {
203                    eggfetch_ffi::eggfetch_client_request(client, method_c.as_ptr(), url_c.as_ptr())
204                };
205                if req.is_null() {
206                    return Err(napi::Error::from_reason("failed to create request"));
207                }
208
209                if let Some(body_str) = &body {
210                    let body_c = match std::ffi::CString::new(body_str.as_str()) {
211                        Ok(body_c) => body_c,
212                        Err(e) => {
213                            unsafe {
214                                eggfetch_ffi::eggfetch_request_free(req);
215                            }
216                            return Err(napi::Error::from_reason(format!(
217                                "invalid body string: {e}"
218                            )));
219                        }
220                    };
221                    let rc =
222                        unsafe { eggfetch_ffi::eggfetch_request_body_str(req, body_c.as_ptr()) };
223                    if rc != 0 {
224                        unsafe {
225                            eggfetch_ffi::eggfetch_request_free(req);
226                        }
227                        return Err(napi::Error::from_reason(format!(
228                            "failed to set request body (code {rc})"
229                        )));
230                    }
231                }
232
233                let mut err: *mut ErrorHandle = ptr::null_mut();
234                let resp = unsafe { eggfetch_ffi::eggfetch_client_send(client, req, &raw mut err) };
235
236                if resp.is_null() {
237                    if !err.is_null() {
238                        let kind = unsafe { eggfetch_ffi::eggfetch_error_kind(err) };
239                        let msg = unsafe { eggfetch_ffi::eggfetch_error_message(err) };
240                        let kind_str = if kind.is_null() {
241                            "unknown".to_owned()
242                        } else {
243                            unsafe { std::ffi::CStr::from_ptr(kind) }
244                                .to_string_lossy()
245                                .into_owned()
246                        };
247                        let msg_str = if msg.is_null() {
248                            "unknown error".to_owned()
249                        } else {
250                            unsafe { std::ffi::CStr::from_ptr(msg) }
251                                .to_string_lossy()
252                                .into_owned()
253                        };
254                        unsafe {
255                            if !kind.is_null() {
256                                eggfetch_ffi::eggfetch_string_free(kind);
257                            }
258                            if !msg.is_null() {
259                                eggfetch_ffi::eggfetch_string_free(msg);
260                            }
261                            eggfetch_ffi::eggfetch_error_free(err);
262                        }
263                        return Err(napi::Error::from_reason(format!(
264                            "eggfetch error [{kind_str}]: {msg_str}"
265                        )));
266                    }
267                    return Err(napi::Error::from_reason(
268                        "request failed with unknown error",
269                    ));
270                }
271
272                Ok(crate::EggfetchResponse::from_raw(resp))
273            })
274            .await
275            .map_err(|e| napi::Error::from_reason(format!("request worker failed: {e}")))?
276        }
277    }
278}
279
280impl Drop for EggfetchClient {
281    fn drop(&mut self) {
282        if !self.inner.0.is_null() {
283            unsafe {
284                eggfetch_ffi::eggfetch_client_free(self.inner.0);
285            }
286        }
287    }
288}