Skip to main content

fetch_happen/
lib.rs

1//! A comfortable wrapper for HTTP requests: the JavaScript `fetch` API on
2//! wasm, and a streaming [`reqwest`] client natively — one API for both.
3use std::fmt;
4
5pub use abort_signal::{AbortController, AbortSignal};
6pub use web_sys::RequestMode;
7
8#[cfg(target_arch = "wasm32")]
9mod web;
10#[cfg(target_arch = "wasm32")]
11pub use web::{RequestBuilder, Response, StreamReader};
12
13#[cfg(not(target_arch = "wasm32"))]
14mod native;
15#[cfg(not(target_arch = "wasm32"))]
16pub use native::{RequestBuilder, Response, StreamReader};
17
18pub type Result<T> = std::result::Result<T, Error>;
19
20/// Errors that can occur when making a request
21#[derive(Debug)]
22pub enum Error {
23    /// Transport-level failure: the fetch call itself on web, or the HTTP
24    /// client natively (DNS, connection, TLS, ...)
25    Transport(String),
26    /// HTTP error with status code
27    HttpError(u16, String),
28    /// JSON parsing error
29    JsonError(String),
30    /// Request was aborted
31    Aborted,
32}
33
34impl From<serde_json::Error> for Error {
35    fn from(err: serde_json::Error) -> Self {
36        Error::JsonError(err.to_string())
37    }
38}
39
40impl fmt::Display for Error {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Error::Transport(e) => write!(f, "Transport error: {}", e),
44            Error::HttpError(status, msg) => write!(f, "HTTP error {}: {}", status, msg),
45            Error::JsonError(e) => write!(f, "JSON error: {}", e),
46            Error::Aborted => write!(f, "Request was aborted"),
47        }
48    }
49}
50
51impl std::error::Error for Error {}
52
53/// HTTP methods
54#[derive(Debug, Clone, Copy)]
55pub enum Method {
56    GET,
57    POST,
58    PUT,
59    DELETE,
60    PATCH,
61    HEAD,
62    OPTIONS,
63}
64
65impl Method {
66    fn as_str(&self) -> &'static str {
67        match self {
68            Method::GET => "GET",
69            Method::POST => "POST",
70            Method::PUT => "PUT",
71            Method::DELETE => "DELETE",
72            Method::PATCH => "PATCH",
73            Method::HEAD => "HEAD",
74            Method::OPTIONS => "OPTIONS",
75        }
76    }
77}
78
79/// Main client for making HTTP requests
80pub struct Client;
81
82impl Client {
83    /// Make a GET request
84    pub fn get(&self, url: impl Into<String>) -> RequestBuilder {
85        RequestBuilder::new(Method::GET, url)
86    }
87
88    /// Make a POST request
89    pub fn post(&self, url: impl Into<String>) -> RequestBuilder {
90        RequestBuilder::new(Method::POST, url)
91    }
92
93    /// Make a PUT request
94    pub fn put(&self, url: impl Into<String>) -> RequestBuilder {
95        RequestBuilder::new(Method::PUT, url)
96    }
97
98    /// Make a DELETE request
99    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder {
100        RequestBuilder::new(Method::DELETE, url)
101    }
102
103    /// Make a PATCH request
104    pub fn patch(&self, url: impl Into<String>) -> RequestBuilder {
105        RequestBuilder::new(Method::PATCH, url)
106    }
107
108    /// Make a HEAD request
109    pub fn head(&self, url: impl Into<String>) -> RequestBuilder {
110        RequestBuilder::new(Method::HEAD, url)
111    }
112}
113
114/// Convenience function for making a GET request
115pub async fn get(url: impl Into<String>) -> Result<Response> {
116    Client.get(url).send().await
117}
118
119/// Convenience function for making a POST request with JSON body
120pub async fn post_json<T: serde::Serialize>(url: impl Into<String>, json: &T) -> Result<Response> {
121    Client.post(url).json(json)?.send().await
122}
123
124#[cfg(all(feature = "examples", target_arch = "wasm32"))]
125pub mod examples {
126    use super::*;
127    use wasm_bindgen::prelude::wasm_bindgen;
128    use web_sys::console;
129
130    /// Example of streaming a large response body in chunks
131    #[wasm_bindgen]
132    pub async fn stream_large_file() {
133        let client = Client;
134        let url = "https://raw.githubusercontent.com/yaptown/yap/refs/heads/main/out/deu/frequency_lists/combined/frequencies.jsonl";
135
136        console::log_1(&"Starting streaming download...".into());
137
138        let response = match client.get(url).send().await {
139            Ok(r) => r,
140            Err(e) => {
141                console::error_1(&format!("Request failed: {}", e).into());
142                return;
143            }
144        };
145
146        let response = match response.error_for_status() {
147            Ok(r) => r,
148            Err(e) => {
149                console::error_1(&format!("HTTP error: {}", e).into());
150                return;
151            }
152        };
153
154        // Get a stream reader
155        let reader = match response.stream_reader() {
156            Ok(r) => r,
157            Err(e) => {
158                console::error_1(&format!("Failed to get stream reader: {}", e).into());
159                return;
160            }
161        };
162
163        let mut total_bytes = 0;
164        let mut chunk_count = 0;
165
166        // Read chunks until the stream is done
167        loop {
168            match reader.read_chunk().await {
169                Ok(Some(chunk)) => {
170                    total_bytes += chunk.len();
171                    chunk_count += 1;
172                    console::log_1(
173                        &format!("Received chunk {}: {} bytes", chunk_count, chunk.len()).into(),
174                    );
175                }
176                Ok(None) => break,
177                Err(e) => {
178                    console::error_1(&format!("Error reading chunk: {}", e).into());
179                    return;
180                }
181            }
182        }
183
184        console::log_1(&format!("✓ Total: {} bytes in {} chunks", total_bytes, chunk_count).into());
185    }
186
187    /// Example of streaming text content line by line
188    #[wasm_bindgen]
189    pub async fn stream_text_content() {
190        let client = Client;
191        let url = "https://raw.githubusercontent.com/yaptown/yap/refs/heads/main/out/deu/frequency_lists/combined/frequencies.jsonl";
192
193        console::log_1(&"Starting line-by-line streaming...".into());
194
195        let response = match client
196            .get(url)
197            .send()
198            .await
199            .and_then(|r| r.error_for_status())
200        {
201            Ok(r) => r,
202            Err(e) => {
203                console::error_1(&format!("Request failed: {}", e).into());
204                return;
205            }
206        };
207
208        let reader = match response.stream_reader() {
209            Ok(r) => r,
210            Err(e) => {
211                console::error_1(&format!("Failed to get stream reader: {}", e).into());
212                return;
213            }
214        };
215
216        let mut buffer = Vec::new();
217        let mut line_count = 0;
218
219        loop {
220            let chunk = match reader.read_chunk().await {
221                Ok(Some(c)) => c,
222                Ok(None) => break,
223                Err(e) => {
224                    console::error_1(&format!("Error reading chunk: {}", e).into());
225                    return;
226                }
227            };
228
229            buffer.extend_from_slice(&chunk);
230
231            // Process complete lines from the buffer
232            while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
233                let line_bytes = buffer.drain(..=newline_pos).collect::<Vec<_>>();
234                let line = String::from_utf8_lossy(&line_bytes);
235                line_count += 1;
236
237                // Only log first few lines to avoid spam
238                if line_count <= 5 {
239                    console::log_1(&format!("Line {}: {}", line_count, line.trim()).into());
240                }
241            }
242        }
243
244        // Process any remaining data in the buffer
245        if !buffer.is_empty() {
246            let line = String::from_utf8_lossy(&buffer);
247            line_count += 1;
248            console::log_1(&format!("Last line: {}", line.trim()).into());
249        }
250
251        console::log_1(&format!("✓ Processed {} lines total", line_count).into());
252    }
253
254    /// Example of downloading with progress tracking
255    #[wasm_bindgen]
256    pub async fn download_with_progress() {
257        let client = Client;
258        let url = "https://raw.githubusercontent.com/yaptown/yap/refs/heads/main/out/deu/frequency_lists/combined/frequencies.jsonl";
259
260        console::log_1(&"Starting download with progress tracking...".into());
261
262        let response = match client
263            .get(url)
264            .send()
265            .await
266            .and_then(|r| r.error_for_status())
267        {
268            Ok(r) => r,
269            Err(e) => {
270                console::error_1(&format!("Request failed: {}", e).into());
271                return;
272            }
273        };
274
275        // Get content length if available
276        let content_length = response
277            .header("content-length")
278            .ok()
279            .flatten()
280            .and_then(|s| s.parse::<usize>().ok());
281
282        if let Some(total) = content_length {
283            console::log_1(&format!("Content-Length: {} bytes", total).into());
284        } else {
285            console::log_1(&"Content-Length not available".into());
286        }
287
288        let reader = match response.stream_reader() {
289            Ok(r) => r,
290            Err(e) => {
291                console::error_1(&format!("Failed to get stream reader: {}", e).into());
292                return;
293            }
294        };
295
296        let mut downloaded = Vec::new();
297        let mut last_logged_percent = 0;
298
299        loop {
300            let chunk = match reader.read_chunk().await {
301                Ok(Some(c)) => c,
302                Ok(None) => break,
303                Err(e) => {
304                    console::error_1(&format!("Error reading chunk: {}", e).into());
305                    return;
306                }
307            };
308
309            downloaded.extend_from_slice(&chunk);
310
311            if let Some(total) = content_length {
312                let progress = (downloaded.len() as f64 / total as f64) * 100.0;
313                let progress_int = progress as u32;
314
315                // Only log every 10%
316                if progress_int >= last_logged_percent + 10 {
317                    console::log_1(
318                        &format!(
319                            "Progress: {:.1}% ({}/{})",
320                            progress,
321                            downloaded.len(),
322                            total
323                        )
324                        .into(),
325                    );
326                    last_logged_percent = progress_int;
327                }
328            }
329        }
330
331        console::log_1(&format!("✓ Download complete: {} bytes", downloaded.len()).into());
332    }
333}