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}