rust-fetch 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,267 +1,445 @@
1
1
  # rust-fetch
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/rust-fetch.svg)](https://www.npmjs.com/package/rust-fetch)
4
- [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
4
+ [![CI](https://github.com/asemqaffaf/rust-fetch/actions/workflows/rust.yml/badge.svg)](https://github.com/asemqaffaf/rust-fetch/actions/workflows/rust.yml)
5
+ [![Coverage: 100% (enforced)](https://img.shields.io/badge/coverage-100%25%20enforced-brightgreen.svg)](https://github.com/asemqaffaf/rust-fetch/actions/workflows/rust.yml)
6
+ [![codecov](https://codecov.io/gh/asemqaffaf/rust-fetch/branch/main/graph/badge.svg)](https://codecov.io/gh/asemqaffaf/rust-fetch)
7
+ [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
5
8
  [![WebAssembly](https://img.shields.io/badge/WebAssembly-654FF0?style=flat&logo=WebAssembly&logoColor=white)](https://webassembly.org/)
6
9
 
7
- A high-performance, world-class HTTP client for JavaScript/TypeScript powered by WebAssembly. Built with Rust for blazing-fast, memory-safe HTTP operations in both browser and Node.js environments.
10
+ **A reliable HTTP client for JavaScript and TypeScript, written in Rust and compiled to WebAssembly.**
11
+ It runs in browsers, Web Workers and Node.js, and adds what `fetch()` leaves to you: timeouts, retries with backoff, typed errors, interceptors, multipart uploads and download progress.
8
12
 
9
- ## 🚀 Features
13
+ ```js
14
+ import { WasmClient } from 'rust-fetch';
10
15
 
11
- - **Type-safe HTTP Client**: Builder pattern for constructing requests with compile-time safety
12
- - **Comprehensive Error Handling**: Detailed error types with context and proper error chaining
13
- - **Automatic Retries**: Built-in retry logic with exponential backoff for transient failures
14
- - **Multiple Response Formats**: Support for JSON, text, and binary responses
15
- - **Platform Optimized**: Specific optimizations for both WASM and native targets
16
- - **Developer Friendly**: Clean API with both simple functions and advanced client builders
17
- - **Backward Compatible**: Maintains compatibility with legacy API while providing modern alternatives
18
-
19
- ## 📦 Installation
20
-
21
- ```bash
22
- npm install rust-fetch
16
+ const api = new WasmClient({ baseUrl: 'https://api.example.com/v1', timeoutMs: 10_000, retries: 3 });
17
+ const { body: user } = await api.get('/users/42');
23
18
  ```
24
19
 
25
- or
20
+ ## Contents
21
+
22
+ - [Features](#features)
23
+ - [Installation](#installation)
24
+ - [Quick start](#quick-start)
25
+ - [Guide](#guide): [client options](#client-options) · [requests](#making-requests) · [bodies](#request-bodies) · [responses](#responses) · [errors](#errors) · [timeouts](#timeouts) · [retries](#retries) · [cancellation](#cancellation) · [streaming](#streaming) · [progress](#download-progress) · [uploads](#file-uploads) · [interceptors](#interceptors) · [TypeScript](#typescript)
26
+ - [Legacy functions](#legacy-functions)
27
+ - [Using it from Rust](#using-it-from-rust)
28
+ - [Performance](#performance)
29
+ - [Roadmap](#roadmap)
30
+ - [Upgrading to 2.0](#upgrading-to-20)
31
+ - [Development](#development)
32
+ - [License](#license)
33
+
34
+ ## Features
35
+
36
+ - **Works everywhere**: browsers (directly or through bundlers like Vite and webpack), Web Workers and Node.js 20+. In Node.js there's no setup: `import` and go.
37
+ - **Timeouts** per attempt, covering the response body, not just the headers. The default is 30 s.
38
+ - **Retries** with exponential backoff and jitter. `Retry-After` is honoured, and POST/PATCH aren't retried unless you opt in.
39
+ - **Typed errors**: every rejection is a real `Error` with a `kind` (`HttpError`, `TimeoutError`, …) and details like `status` and `body`.
40
+ - **Cancellation** with `AbortSignal`, including during retry backoff and while streaming.
41
+ - **Request bodies** from strings, bytes, `Blob`/`File`, `FormData` (multipart with files), `URLSearchParams`, or JSON.
42
+ - **Response decoding** by `Content-Type` (including `application/*+json`), or forced to `json`, `text`, `binary` or `stream`.
43
+ - **Streaming** responses as a `ReadableStream`, and **download progress** callbacks.
44
+ - **Interceptors** for requests and responses, e.g. auth headers and token refresh.
45
+ - **TypeScript** definitions for the whole API.
46
+ - **Tested**: 100% line coverage of the Rust core, plus wasm tests and end-to-end tests of the packed npm package. See [Development](#development).
47
+
48
+ ## Installation
26
49
 
27
50
  ```bash
28
- yarn add rust-fetch
51
+ npm install rust-fetch
29
52
  ```
30
53
 
31
- ## 🔧 Usage
54
+ ## Quick start
32
55
 
33
- ### Simple API
56
+ **Node.js** (20+): the WebAssembly module loads on import.
34
57
 
35
- For quick and easy HTTP requests:
58
+ ```js
59
+ import { WasmClient } from 'rust-fetch';
36
60
 
37
- ### WASM Client Example
38
- How to use the imported `WasmClient` to make HTTP requests from the browser or Node.js environment:
61
+ const client = new WasmClient();
62
+ const res = await client.get('https://jsonplaceholder.typicode.com/todos/1');
63
+ console.log(res.status, res.body.title);
64
+ ```
39
65
 
40
- #### Simple GET Request
41
- Here's how to make a simple GET request using `WasmClient`:
66
+ **Browsers and bundlers**: call `init()` once before the first request. It downloads and compiles the `.wasm` file.
42
67
 
43
- ```javascript
68
+ ```js
44
69
  import init, { WasmClient } from 'rust-fetch';
45
70
 
46
71
  await init();
47
-
48
72
  const client = new WasmClient();
49
-
50
- client.get('https://jsonplaceholder.typicode.com/posts/1')
51
- .then(response => console.log(response))
52
- .catch(error => console.error('Error:', error));
53
73
  ```
54
74
 
55
- #### POST Request with JSON
56
- Here's how to make a POST request with a JSON body:
75
+ `init()` is safe to call more than once, and calling it in Node.js is harmless, so shared code can always call it.
57
76
 
58
- ```javascript
59
- const data = { title: 'foo', body: 'bar', userId: 1 };
77
+ ## Guide
60
78
 
61
- client.post_json('https://jsonplaceholder.typicode.com/posts', data)
62
- .then(response => console.log(response))
63
- .catch(error => console.error('Error:', error));
64
- ```
79
+ ### Client options
65
80
 
66
- ### Using Fetch Functions
81
+ ```ts
82
+ const client = new WasmClient({
83
+ baseUrl: 'https://api.example.com/v1', // relative request URLs are appended to this
84
+ headers: { Authorization: 'Bearer …' }, // sent with every request
85
+ timeoutMs: 10_000, // per attempt; 0 disables. Default: 30000
86
+ retries: 3, // or a RetryOptions object, see Retries
87
+ });
88
+ ```
67
89
 
68
- ```javascript
69
- import init, { fetch_json, fetch_text } from 'rust-fetch';
90
+ Invalid options throw an `InvalidInputError` right away, from the constructor.
70
91
 
71
- // Initialize WASM module
72
- await init();
92
+ ### Making requests
73
93
 
74
- // Fetch JSON data
75
- try {
76
- const data = await fetch_json('https://api.example.com/data');
77
- console.log('JSON data:', data);
78
- } catch (error) {
79
- console.error('Error fetching JSON:', error);
80
- }
94
+ | Method | Use |
95
+ | --- | --- |
96
+ | `get(url, options?)`, `head(url, options?)`, `delete(url, options?)` | Requests without a body |
97
+ | `post(url, init?)`, `put(url, init?)`, `patch(url, init?)` | `init.body` is the body; any other [request option](#request-options) can go next to it |
98
+ | `fetch(url, init?)` | `fetch`-style, with `init.method` (default `GET`) |
99
+ | `post_json`, `put_json`, `patch_json(url, body, options?)` | Always send `body` as JSON |
100
+ | `request(method, url, headers?, body?, options?)` | Fully positional |
81
101
 
82
- // Fetch text/HTML
83
- try {
84
- const html = await fetch_text('https://example.com');
85
- console.log('HTML content:', html);
86
- } catch (error) {
87
- console.error('Error fetching HTML:', error);
88
- }
102
+ ```js
103
+ await client.post('/users', { body: { name: 'Zoë' } });
104
+ await client.get('/search', { params: { q: 'rust wasm', page: 2, tag: ['a', 'b'] } }); // ?q=rust+wasm&page=2&tag=a&tag=b
105
+ await client.fetch('/users/1', { method: 'DELETE', headers: { 'X-Reason': 'cleanup' } });
89
106
  ```
90
107
 
91
- ### Advanced Client API
108
+ Relative URLs need a `baseUrl`. In a browser page without one, they resolve against the page URL. Absolute URLs always win over `baseUrl`. `TRACE` and `CONNECT` are rejected because `fetch()` forbids them.
109
+
110
+ #### Request options
92
111
 
93
- For more control and enhanced configurations, use the `fetch_with_options` to fine-tune HTTP requests:
112
+ | Option | Type | Description |
113
+ | --- | --- | --- |
114
+ | `headers` | object, `Headers`, `Map` or `[name, value][]` | Override client headers with the same name. Numbers and booleans are converted to strings. |
115
+ | `params` | object or `URLSearchParams` | Appended to the query string. Arrays repeat the key; `null`/`undefined` are skipped. |
116
+ | `timeoutMs` | number | Overrides the client timeout; `0` disables it |
117
+ | `responseType` | `'auto'` \| `'json'` \| `'text'` \| `'binary'` \| `'stream'` | See [Responses](#responses) |
118
+ | `signal` | `AbortSignal` | See [Cancellation](#cancellation) |
119
+ | `onDownloadProgress` | `({ loaded, total }) => void` | See [Download progress](#download-progress) |
94
120
 
95
- #### Using Advanced Options
96
- You can specify HTTP method, headers, and body:
121
+ ### Request bodies
97
122
 
98
- ```javascript
99
- import { fetch_with_options } from 'rust-fetch';
123
+ The body's type decides how it's sent. A `Content-Type` header you set yourself is always kept, except for `FormData`, which needs its own boundary.
100
124
 
101
- const url = 'https://jsonplaceholder.typicode.com/posts';
102
- const method = 'POST';
103
- const headers = { 'Content-Type': 'application/json' };
104
- const body = JSON.stringify({ userId: 1, title: 'foo', body: 'bar' });
125
+ | Body | Sent as | Default `Content-Type` |
126
+ | --- | --- | --- |
127
+ | `string` | as-is | `text/plain` |
128
+ | `Uint8Array`, `ArrayBuffer`, `DataView`, `Buffer` | bytes | `application/octet-stream` |
129
+ | `Blob` / `File` | bytes | the blob's `type` |
130
+ | `FormData` | `multipart/form-data`, including files | `multipart/form-data; boundary=…` |
131
+ | `URLSearchParams` | form-encoded | `application/x-www-form-urlencoded;charset=UTF-8` |
132
+ | anything else (objects, arrays, numbers, booleans) | `JSON.stringify` | `application/json` |
105
133
 
106
- fetch_with_options(url, method, headers, body)
107
- .then(response => console.log(response))
108
- .catch(error => console.error('Error:', error));
134
+ `null` and `undefined` mean no body. The `*_json` methods are the exception: they always send JSON, so `post_json(url, null)` sends `null`. `ReadableStream` request bodies aren't supported; send a `Blob` or bytes instead.
135
+
136
+ ### Responses
137
+
138
+ Successful (2xx) requests resolve to:
139
+
140
+ ```ts
141
+ interface HttpResponse<T = any> {
142
+ status: number; // 200
143
+ statusText: string; // "OK"
144
+ ok: boolean; // always true: non-2xx responses reject with an HttpError
145
+ url: string; // final URL after redirects
146
+ headers: Record<string, string>; // lower-cased names; repeated headers joined with ", "
147
+ body: T;
148
+ }
109
149
  ```
110
150
 
111
- ## 🏗️ Architecture
151
+ With the default `responseType: 'auto'`, `body` is:
152
+ - **parsed JSON** for `application/json` and `application/*+json` (e.g. `problem+json`), falling back to text if it isn't valid JSON
153
+ - **a string** for `text/*`, XML, JavaScript and form-encoded responses
154
+ - **a `Uint8Array`** for anything else
155
+ - **`null`** when the body is empty (e.g. `204` or `HEAD`)
112
156
 
113
- The library is organized into several modules:
157
+ Force a type with `responseType: 'json'` (fails with a `ParseError` if the body isn't JSON), `'text'`, `'binary'` or `'stream'`. JSON is parsed by the JS engine's `JSON.parse`, exactly like `Response.json()`.
114
158
 
115
- - **`error`**: Comprehensive error types with detailed context
116
- - **`types`**: Core types like `Method`, `Headers`, `Response`, etc.
117
- - **`client`**: The main HTTP client with builder pattern
118
- - **`http`**: High-level convenience functions for common use cases
159
+ ### Errors
119
160
 
120
- ### Error Handling
161
+ Every rejection is an `Error` whose `name` and `kind` are one of:
121
162
 
122
- The library provides rich error types that preserve context:
163
+ | `kind` | When | Extra fields |
164
+ | --- | --- | --- |
165
+ | `HttpError` | Non-2xx response | `status`, `statusText`, `body` (full response body as text) |
166
+ | `TimeoutError` | An attempt exceeded `timeoutMs` | `timeoutMs` |
167
+ | `NetworkError` | DNS, connection, TLS or CORS failure, or a body cut off mid-transfer | |
168
+ | `CancelledError` | The `signal` was aborted | |
169
+ | `InvalidInputError` | Bad URL, method, header, option or body. Rejected before anything is sent. | `parameter` |
170
+ | `ParseError` | `responseType: 'json'` and the body isn't JSON | |
171
+ | `JsInteropError` | Unexpected JS ↔ Wasm conversion failure | |
123
172
 
124
- ```javascript
173
+ ```js
125
174
  try {
126
- const data = await fetch_json(url);
127
- console.log('Success:', data);
175
+ await client.get('/users/9');
128
176
  } catch (error) {
129
- // Error handling with detailed context
130
- console.error('Failed to fetch:', error.message);
177
+ if (error.kind === 'HttpError' && error.status === 404) {
178
+ // not found
179
+ } else if (error.kind === 'TimeoutError') {
180
+ console.warn(`gave up after ${error.timeoutMs} ms`);
181
+ } else {
182
+ throw error;
183
+ }
131
184
  }
132
185
  ```
133
186
 
134
- ## API Reference
187
+ Long error bodies (such as an HTML error page) are truncated to 200 characters in `message`. `error.body` always has the full text.
135
188
 
136
- ### HTTP Functions
189
+ ### Timeouts
137
190
 
138
- #### `fetch_json(url: string): Promise<any>`
139
- Fetches JSON data from the specified URL and returns it as a JavaScript object.
191
+ `timeoutMs` limits each attempt, from sending the request to receiving the last byte of the body. When retries are on, each attempt gets the full timeout. For `responseType: 'stream'`, the timeout covers only the wait for the headers; after that, reading the stream is up to you.
140
192
 
141
- ```javascript
142
- import { fetch_json } from 'rust-fetch';
193
+ ### Retries
143
194
 
144
- const data = await fetch_json('https://jsonplaceholder.typicode.com/todos/1');
195
+ Retries are off by default. `retries: 3` enables them with these defaults; pass an object to change them:
196
+
197
+ ```ts
198
+ new WasmClient({
199
+ retries: {
200
+ maxRetries: 3, // retries after the first attempt
201
+ initialDelayMs: 100, // first backoff
202
+ maxDelayMs: 10_000, // cap for any delay, including Retry-After
203
+ multiplier: 2, // exponential backoff: 100, 200, 400 … ms, each randomized to 50–100%
204
+ retryOn: [408, 429, 500, 502, 503, 504],
205
+ retryOnNetworkError: true,
206
+ retryOnTimeout: true,
207
+ retryNonIdempotent: false, // set to true to also retry POST and PATCH
208
+ },
209
+ });
145
210
  ```
146
211
 
147
- #### `fetch_text(url: string): Promise<string>`
148
- Fetches text/HTML content from the specified URL and returns it as a string.
212
+ - A `Retry-After` header in seconds is honoured, capped at `maxDelayMs`.
213
+ - POST and PATCH aren't retried by default. The server may already have acted on a failed attempt, so retrying can duplicate work.
214
+ - `InvalidInputError`, `ParseError` and unlisted statuses are never retried.
149
215
 
150
- ```javascript
151
- import { fetch_text } from 'rust-fetch';
216
+ ### Cancellation
152
217
 
153
- const html = await fetch_text('https://example.com');
218
+ ```js
219
+ const controller = new AbortController();
220
+ const promise = client.get('/report', { signal: controller.signal });
221
+ controller.abort(); // rejects with a CancelledError
222
+
223
+ await client.get('/report', { signal: AbortSignal.timeout(5_000) }); // total deadline, including retries
154
224
  ```
155
225
 
156
- #### `fetch_with_options(url: string, method: string, headers: object, body?: string): Promise<object>`
157
- Advanced fetch function with full control over the request.
226
+ Aborting cancels the underlying `fetch`, any pending retry delay, and a body being streamed.
227
+
228
+ ### Streaming
158
229
 
159
- ```javascript
160
- import { fetch_with_options } from 'rust-fetch';
230
+ `responseType: 'stream'` resolves as soon as the headers arrive. `body` is then a `ReadableStream<Uint8Array>`:
161
231
 
162
- const response = await fetch_with_options(
163
- 'https://api.example.com/users',
164
- 'POST',
165
- { 'Content-Type': 'application/json' },
166
- JSON.stringify({ name: 'John Doe' })
167
- );
232
+ ```js
233
+ const res = await client.get('/export.csv', { responseType: 'stream' });
234
+ for await (const chunk of res.body) {
235
+ process(chunk); // Uint8Array
236
+ }
168
237
  ```
169
238
 
170
- ### WasmClient Class
239
+ `for await` over a stream works in Node.js, Chrome and Firefox. Safari doesn't support it yet, so use `res.body.getReader()` there. Retries apply until the headers arrive, and error statuses still reject with an `HttpError`.
171
240
 
172
- #### `new WasmClient()`
173
- Creates a new HTTP client instance.
241
+ ### Download progress
174
242
 
175
- ```javascript
176
- const client = new WasmClient();
243
+ ```js
244
+ await client.get('/video.mp4', {
245
+ responseType: 'binary',
246
+ onDownloadProgress: ({ loaded, total }) => {
247
+ console.log(total ? `${Math.round((loaded / total) * 100)}%` : `${loaded} bytes`);
248
+ },
249
+ });
177
250
  ```
178
251
 
179
- #### `client.get(url: string): Promise<object>`
180
- Performs a GET request.
252
+ The callback runs after each chunk of a successful response, including streamed ones. `total` comes from `Content-Length` and is `null` when the server doesn't send it. Exceptions thrown by the callback are ignored.
253
+
254
+ Upload progress isn't available, because `fetch()` doesn't report it.
181
255
 
182
- ```javascript
183
- const response = await client.get('https://api.example.com/data');
256
+ ### File uploads
257
+
258
+ Pass a `FormData` and it's sent as `multipart/form-data`, files included:
259
+
260
+ ```js
261
+ const form = new FormData();
262
+ form.append('title', 'Quarterly report');
263
+ form.append('file', fileInput.files[0]); // or new File([bytes], 'report.pdf', { type: 'application/pdf' })
264
+ await client.post('/upload', { body: form });
184
265
  ```
185
266
 
186
- #### `client.post_json(url: string, body: object): Promise<object>`
187
- Performs a POST request with JSON body.
267
+ A single `Blob` or `File` body is sent as raw bytes with the blob's type.
268
+
269
+ ### Interceptors
270
+
271
+ ```js
272
+ // Add a header to every request (may be async)
273
+ client.addRequestInterceptor((request) => {
274
+ request.headers.authorization = `Bearer ${getToken()}`;
275
+ // mutate in place, or return a new { method, url, headers, body }
276
+ });
277
+
278
+ // Resolve to the body instead of the whole response
279
+ client.addResponseInterceptor((response) => response.body);
188
280
 
189
- ```javascript
190
- const response = await client.post_json('https://api.example.com/users', {
191
- name: 'Jane Doe',
192
- email: 'jane@example.com'
281
+ // Report failures, then keep rejecting
282
+ client.addResponseInterceptor(undefined, (error) => {
283
+ reportError(error);
284
+ throw error;
193
285
  });
194
286
  ```
195
287
 
288
+ An `onRejected` handler can also recover by returning a value, for example the result of retrying the request after refreshing a token.
289
+
290
+ - Request interceptors run in order before each request. They see `{ method, url, headers, body }`, where `headers` holds the per-request headers. Client default headers are added afterwards, and a request header with the same name wins.
291
+ - Response interceptors chain like `promise.then(onFulfilled, onRejected)`. Their return values are what the call resolves to. Throw to reject.
292
+ - `addRequestInterceptor` and `addResponseInterceptor` return an id for `ejectInterceptor(id)`. `clearInterceptors()` removes all of them.
293
+ - A request uses the interceptors registered when it started.
294
+
295
+ ### TypeScript
196
296
 
197
- ## Performance Considerations
297
+ Type definitions ship with the package and need TypeScript 5.2 or newer. Besides `WasmClient`, it exports the option and result types `ClientOptions`, `RequestOptions`, `FetchInit`, `RetryOptions`, `HttpResponse`, `RustFetchError`, `DownloadProgress` and `InterceptedRequest`.
198
298
 
199
- - The WASM module adds a small overhead for initialization (~100-200ms on first load)
200
- - Once loaded, HTTP operations benefit from Rust's efficient memory management
201
- - Best suited for applications that make multiple HTTP requests or need predictable performance
299
+ ```ts
300
+ import { WasmClient, type HttpResponse, type RustFetchError } from 'rust-fetch';
202
301
 
203
- ## Browser Support
302
+ interface User { id: number; name: string }
204
303
 
205
- - Chrome 57+
206
- - Firefox 52+
207
- - Safari 11+
208
- - Edge 16+
209
- - Node.js 14+
304
+ const res: HttpResponse<User> = await client.get('/users/42');
305
+ res.body.name; // string
210
306
 
211
- ## Contributing
307
+ try {
308
+ await client.get('/users/9');
309
+ } catch (e) {
310
+ const error = e as RustFetchError;
311
+ if (error.kind === 'HttpError') console.log(error.status);
312
+ }
313
+ ```
212
314
 
213
- Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
315
+ ## Legacy functions
214
316
 
215
- 1. Fork the repository
216
- 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
217
- 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
218
- 4. Push to the branch (`git push origin feature/amazing-feature`)
219
- 5. Open a Pull Request
317
+ These standalone functions still work. Each call uses a default client: 30 s timeout, no retries.
220
318
 
221
- ## License
319
+ | Function | Resolves to |
320
+ | --- | --- |
321
+ | `fetch_json(url)` / `fetch_json_promise(url)` | Parsed JSON, whatever the `Content-Type`; `null` for an empty body |
322
+ | `fetch_text(url)` | The body as a string |
323
+ | `fetch_with_options(url, method, headers, body?)` | The full `HttpResponse` |
324
+ | `create_client()` | `new WasmClient()` |
325
+ | `fetch_wasm_json`, `fetch_wasm_map`, `fetch_wasm_api`, `fetch_wasm_html` | Deprecated aliases of `fetch_json` / `fetch_text` |
222
326
 
223
- This project is licensed under the ISC License - see the LICENSE file for details.
327
+ ## Using it from Rust
224
328
 
225
- ## Troubleshooting
329
+ The same client is a Rust library, for native code (on tokio) and for your own wasm crates:
226
330
 
227
- ### WASM Module Not Loading
331
+ ```rust
332
+ use rust_fetch::{Client, Multipart, types::RetryConfig};
333
+ use std::time::Duration;
228
334
 
229
- If you encounter issues with the WASM module not loading:
335
+ let client = Client::builder()
336
+ .base_url("https://api.example.com/v1")
337
+ .timeout(Duration::from_secs(10))
338
+ .retry_config(RetryConfig { max_retries: 3, ..Default::default() })
339
+ .build()?;
230
340
 
231
- 1. Ensure your server supports the `application/wasm` MIME type
232
- 2. Check that the WASM file is being served correctly
233
- 3. Verify you're awaiting the `initWasm()` call before using any functions
341
+ let user = client.get("users/42").await?;
342
+ println!("{}", user.json().unwrap()["name"]);
234
343
 
235
- ### CORS Issues
344
+ let pdf: Vec<u8> = std::fs::read("report.pdf")?;
345
+ let form = Multipart::new().text("title", "Report").file("file", "report.pdf", Some("application/pdf".into()), pdf);
346
+ client.post("upload").multipart(&form).send().await?;
236
347
 
237
- When fetching from external APIs, ensure:
348
+ let mut chunks = client.get_streaming("export.csv").await?.into_stream(); // a futures::Stream of Vec<u8>
349
+ ```
238
350
 
239
- 1. The target server allows CORS requests
240
- 2. You're using HTTPS URLs in production
241
- 3. Consider using a proxy server for APIs that don't support CORS
351
+ Request futures are `Send`, so you can `tokio::spawn` them. Progress callbacks (`on_download_progress`) must be `Send + Sync` on native targets.
242
352
 
243
- ### Node.js Path Issues
353
+ ## Performance
244
354
 
245
- If you encounter path resolution issues in Node.js:
355
+ rust-fetch makes its requests through the runtime's own `fetch()`, so the network work is the same as plain `fetch`. The extra cost comes from crossing the JS ↔ Wasm boundary and copying bodies. It is **not faster than native `fetch`**. Use it for the reliability features, not for speed.
246
356
 
247
- 1. Ensure the package is installed correctly
248
- 2. Check that the WASM file exists in `node_modules/rust-fetch/wasm-module/pkg/`
249
- 3. Try reinstalling the package
357
+ `npm run bench` (Node 22.13, Apple M1 Pro, local server):
250
358
 
251
- ## Acknowledgments
359
+ | Scenario | native `fetch` | rust-fetch | rust-fetch / native |
360
+ | --- | ---: | ---: | ---: |
361
+ | Small JSON, sequential | 5,465 req/s | 4,015 req/s | 73% |
362
+ | Small JSON, 50 concurrent | 12,155 req/s | 6,035 req/s | 50% |
363
+ | JSON 0.6 MB | 219 req/s | 212 req/s | 97% |
364
+ | Binary 8 MB | 189 req/s | 147 req/s | 77% |
252
365
 
253
- - Built with [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen)
254
- - Uses [reqwest](https://github.com/seanmonstar/reqwest) for HTTP operations
255
- - Inspired by the Rust and WebAssembly community
366
+ The overhead per request is a fraction of a millisecond, which matters little once a request crosses a real network.
256
367
 
257
368
  ## Roadmap
258
369
 
259
- - [ ] Add support for POST, PUT, DELETE methods
260
- - [ ] Implement request headers customization
261
- - [ ] Add streaming support for large responses
262
- - [ ] Implement request cancellation
263
- - [ ] Add comprehensive test suite
264
- - [ ] Performance benchmarks against native fetch
265
- - [ ] Support for form data and file uploads
370
+ - [x] POST, PUT, PATCH, DELETE, HEAD, OPTIONS
371
+ - [x] Request header customization
372
+ - [x] Comprehensive test suite: Rust unit and integration tests (100% line coverage, enforced in CI), wasm tests, and JS end-to-end tests
373
+ - [x] Streaming responses (`responseType: 'stream'`; `send_streaming()` in Rust)
374
+ - [x] Request cancellation (`AbortSignal`)
375
+ - [x] Performance benchmarks against native fetch (`npm run bench`)
376
+ - [x] Multipart form data and file uploads
377
+ - [x] Request/response interceptors
378
+ - [x] Download progress tracking
379
+ - [ ] Upload progress tracking: not possible on top of `fetch()`, which has no upload progress events
266
380
  - [ ] WebSocket support
267
- - [ ] Request/response interceptors
381
+ - Handled by the runtime, not by rust-fetch:
382
+ - **Connection pooling:** browsers and Node.js pool connections for `fetch()` themselves.
383
+ - **Proxies:** set them up in the runtime, e.g. with undici's `setGlobalDispatcher(new ProxyAgent(url))` in Node.js, or with system settings in browsers.
384
+ - **HTTP/2:** browsers negotiate it automatically; in Node.js it depends on undici's dispatcher (`allowH2`).
385
+
386
+ ## Upgrading to 2.0
387
+
388
+ 2.0 fixes long-standing bugs, and some code may have depended on the old behaviour. Check these points:
389
+
390
+ - **Timeouts are enforced** in browsers and Node.js. In 1.x the 30 s default was silently ignored, so slow requests never failed. For long downloads, set `timeoutMs` higher, or to `0` to disable it.
391
+ - **`WasmClient` JSON bodies are plain objects**, not `Map`s: use `body.title`, not `body.get('title')`. JSON with integers above 2^53 no longer fails the request.
392
+ - **More responses are decoded:** `application/*+json` (e.g. `problem+json`) is parsed as JSON instead of returned as bytes, and empty bodies are `null`.
393
+ - **Errors are real `Error` objects.** `kind`, `message`, `status` and the other fields are unchanged, and `name` equals `kind`. Like any `Error`, `message` isn't enumerable, so `JSON.stringify(error)` leaves it out.
394
+ - **Invalid `headers` values reject** with an `InvalidInputError`; they used to be dropped silently. Numbers and booleans are converted to strings.
395
+ - **Text is decoded as UTF-8**, whatever the `charset` in `Content-Type`. Invalid bytes become `�`.
396
+ - **The package uses an `exports` map.** Import `rust-fetch`, `rust-fetch/rust_fetch.js` or `rust-fetch/rust_fetch_bg.wasm`.
397
+ - **Node.js 20+ is required.** In Node.js you no longer need to load the `.wasm` file yourself; `init()` is optional there.
398
+
399
+ These changed too, but are unlikely to break anything:
400
+ - POST and PATCH aren't retried by default (`retryNonIdempotent: true` re-enables it).
401
+ - Your `Content-Type` is no longer duplicated.
402
+ - `fetch_json` parses JSON whatever the `Content-Type`.
403
+ - Invalid URLs, and `TRACE`/`CONNECT`, fail fast with an `InvalidInputError`.
404
+
405
+ Rust crate users:
406
+ - `RetryConfig` has a new field, so add `..Default::default()` to struct literals.
407
+ - `Method::to_reqwest()` returns reqwest 0.12's `Method`.
408
+ - `RequestBuilder::header()` replaces a default header with the same name instead of adding a second value.
409
+
410
+ 1.x keeps receiving critical fixes on the [`v1.x`](https://github.com/asemqaffaf/rust-fetch/tree/v1.x) branch (`npm install rust-fetch@1`).
411
+
412
+ ## Development
413
+
414
+ The npm package is built from `wasm-module/` with [wasm-pack](https://rustwasm.github.io/wasm-pack/). The repository root is a private workspace.
415
+
416
+ ```bash
417
+ npm run build # wasm-pack build + package metadata (wasm-module/pkg)
418
+ npm test # Rust unit + integration tests, then the JS tests
419
+ npm run test:js # JS end-to-end tests against the built package (requires a build)
420
+ npm run bench # rust-fetch vs native fetch (requires a build)
421
+ npm run size # size budget for the built package
422
+ npm run api:check # public TypeScript API vs the committed snapshot
423
+
424
+ cd wasm-module
425
+ wasm-pack test --node # wasm unit tests for the JS bindings
426
+ cargo llvm-cov --ignore-filename-regex 'src/(wasm/|http\.rs)|tests/' # coverage
427
+ ```
428
+
429
+ | Suite | Where | What |
430
+ | --- | --- | --- |
431
+ | Rust unit tests | `wasm-module/src/**` | URL handling, retry policy, decoding, errors, multipart |
432
+ | Rust integration tests | `wasm-module/tests/integration_tests.rs` | Real HTTP against a local server: retries, timeouts, redirects, chunked/truncated/large bodies, streaming, progress, concurrency |
433
+ | Wasm unit tests | `wasm-module/src/wasm/tests.rs` | JS conversions, bodies, options, interceptors, timers and abort signals in Node.js |
434
+ | JS end-to-end tests | `wasm-module/js/test/client.test.mjs` | The built package against a local server |
435
+ | Package tests | `wasm-module/js/test/package.test.mjs` | Installs the packed tarball and uses it via `import`, `require`, the browser entry and its types |
436
+
437
+ CI runs the Rust suites with a coverage gate that fails if line or function coverage of the Rust core drops below 100%. It also runs the wasm and JS suites on Node 20, 22 and 24, and checks formatting, dependencies (security advisories and licenses), API compatibility and the size budget. See [CONTRIBUTING.md](CONTRIBUTING.md#pull-requests). The JS-only bindings in `src/wasm/` and `src/http.rs` need a JS engine, so they're covered by the wasm and JS suites instead.
438
+
439
+ Releases are published by CI when a version tag is pushed. See [CONTRIBUTING.md](CONTRIBUTING.md#releasing).
440
+
441
+ ## License
442
+
443
+ Licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
444
+
445
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development and release process, and [SECURITY.md](SECURITY.md) to report a vulnerability.
package/node.js ADDED
@@ -0,0 +1,13 @@
1
+ // Node.js entry point (selected through the "node" export condition).
2
+ //
3
+ // Browsers load the .wasm file with fetch(), which Node.js can't do for
4
+ // file: URLs. Here the module is compiled synchronously on import instead, so
5
+ // the API is ready to use immediately; calling `init()` is optional and
6
+ // returns the already-initialized module.
7
+ import { readFileSync } from 'node:fs';
8
+ import { initSync } from './rust_fetch.js';
9
+
10
+ initSync({ module: readFileSync(new URL('./rust_fetch_bg.wasm', import.meta.url)) });
11
+
12
+ export { default } from './rust_fetch.js';
13
+ export * from './rust_fetch.js';