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