rust-fetch 2.1.0 → 2.2.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
@@ -3,57 +3,26 @@
3
3
  [![npm version](https://img.shields.io/npm/v/rust-fetch.svg)](https://www.npmjs.com/package/rust-fetch)
4
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
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)
6
+ [![codecov](https://codecov.io/gh/asemqaffaf/rust-fetch/graph/badge.svg)](https://app.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)](#contributing-and-license)
8
8
  [![WebAssembly](https://img.shields.io/badge/WebAssembly-654FF0?style=flat&logo=WebAssembly&logoColor=white)](https://webassembly.org/)
9
9
 
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.
10
+ **A resilient HTTP client for JavaScript and TypeScript: `fetch()` plus the timeouts, retries, typed errors and interceptors that production code needs, from one Rust core that behaves the same in browsers, Web Workers and Node.js.**
12
11
 
13
- ```js
14
- import { WasmClient } from 'rust-fetch';
15
-
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');
18
- ```
19
-
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 and of the Node.js transport, plus wasm tests and end-to-end tests of the packed npm package. See [Development](#development).
12
+ - **Errors you can branch on.** Every failure rejects with a typed `Error`: `HttpError` (with the status and response body), `TimeoutError`, `NetworkError`, `CancelledError` and [more](#errors), so one `catch` handles them all.
13
+ - **Timeouts and retries done right.** `timeoutMs` covers each attempt up to the last byte of the body. Retries back off with jitter, honour `Retry-After`, and never repeat a POST or PATCH unless you allow it.
14
+ - **One core, every runtime.** The request policy is written once in Rust and compiled to WebAssembly, so a request is validated, retried and timed out the same way in a browser tab and in a Node.js service.
15
+ - **More than `fetch()` offers.** Interceptors, streaming, and download and upload progress.
16
+ - **One client for HTTP and WebSockets.** `client.websocket()` shares the client's base URL, timeouts and typed errors, and adds reconnection with backoff.
17
+ - **Steady under load.** In an [independent benchmark](#independent-benchmark-of-rust-fetch-210) at 15,000 requests per second, the published client's p99 latency was **11.2 ms against 450 ms** for Node.js `fetch()`, with a third of the memory. The native transport, not in the npm package yet, reached up to **1.95×** `fetch()`'s throughput ([performance](#performance)).
47
18
 
48
- ## Installation
19
+ ## Quick start
49
20
 
50
21
  ```bash
51
22
  npm install rust-fetch
52
23
  ```
53
24
 
54
- ## Quick start
55
-
56
- **Node.js** (20+): the WebAssembly module loads on import.
25
+ **Node.js** (20 or newer): the WebAssembly module loads on import.
57
26
 
58
27
  ```js
59
28
  import { WasmClient } from 'rust-fetch';
@@ -74,7 +43,267 @@ const client = new WasmClient();
74
43
 
75
44
  `init()` is safe to call more than once, and calling it in Node.js is harmless, so shared code can always call it.
76
45
 
77
- ## Guide
46
+ **A client set up for production**, with a base URL, timeouts, retries and error handling:
47
+
48
+ ```js
49
+ import { WasmClient } from 'rust-fetch';
50
+
51
+ const api = new WasmClient({
52
+ baseUrl: 'https://api.example.com/v1',
53
+ timeoutMs: 10_000, // per attempt, including reading the body
54
+ retries: 3, // exponential backoff with jitter; honours Retry-After
55
+ });
56
+
57
+ try {
58
+ const { body: user } = await api.get('/users/42');
59
+ } catch (error) {
60
+ if (error.kind === 'HttpError' && error.status === 404) {
61
+ // not found: error.body has the response text
62
+ } else if (error.kind === 'TimeoutError') {
63
+ // the last attempt took over 10 s, with no retries left
64
+ } else {
65
+ throw error;
66
+ }
67
+ }
68
+ ```
69
+
70
+ > **Release status**
71
+ > - **On npm (2.2.0):** everything in this README, including [upload progress](#upload-progress) and [WebSockets](#websockets), except what's listed below. Node.js requests use a `node:http` transport with a bounded connection pool; browsers use `fetch()`.
72
+ > - **In the repository, not yet in the npm package:** the [native Node.js transport](#the-native-transport-not-in-the-npm-package-yet) behind the largest performance gains below. The package doesn't include its prebuilt binaries yet; build it with `npm run build:native`.
73
+
74
+ ## Contents
75
+
76
+ - [Why rust-fetch?](#why-rust-fetch)
77
+ - [Features](#features)
78
+ - [Performance](#performance)
79
+ - [Usage and configuration](#usage-and-configuration): [client options](#client-options) · [requests](#making-requests) · [bodies](#request-bodies) · [responses](#responses) · [errors](#errors) · [timeouts](#timeouts) · [retries](#retries) · [cancellation](#cancellation) · [interceptors](#interceptors) · [streaming](#streaming) · [download progress](#download-progress) · [uploads](#file-uploads) · [upload progress](#upload-progress) · [WebSockets](#websockets) · [TypeScript](#typescript) · [legacy functions](#legacy-functions)
80
+ - [Using it from Rust](#using-it-from-rust)
81
+ - [Architecture](#architecture)
82
+ - [Compatibility and limitations](#compatibility-and-limitations)
83
+ - [Development and benchmarking](#development-and-benchmarking)
84
+ - [FAQ](#faq)
85
+ - [Contributing and license](#contributing-and-license)
86
+
87
+ ## Why rust-fetch?
88
+
89
+ `fetch()` gives you a request primitive; rust-fetch gives you the policy around it. With `fetch()`, every caller checks `res.ok`, wires up an `AbortSignal` for timeouts, and writes its own retry loop. rust-fetch does that once, correctly, for every request.
90
+
91
+ ### Compared with `fetch()` and axios
92
+
93
+ | | `fetch()` | axios | rust-fetch |
94
+ | --- | --- | --- | --- |
95
+ | Rejects on HTTP errors (4xx, 5xx) | No, check `res.ok` | Yes | Yes, with `status` and the body |
96
+ | Timeouts | Wire up `AbortSignal.timeout()` | `timeout` option | `timeoutMs` per attempt, including the body; 30 s by default |
97
+ | Retries with backoff | No | With a plugin (`axios-retry`) | Built in: jitter, `Retry-After`, no POST or PATCH retries unless enabled |
98
+ | Interceptors | No | Yes | Yes |
99
+ | Download progress | Read the stream yourself | Yes | Yes |
100
+ | Upload progress | No | Yes | Yes |
101
+ | Reuses connections in Node.js by default | Yes | No, needs a keep-alive agent | Yes, up to 256 per origin |
102
+ | Added to a browser bundle (brotli) | Nothing, it's built in | About 21 KB | About 207 KB: the WebAssembly module (198 KB) and its JS glue |
103
+
104
+ The rust-fetch column describes the JavaScript API; the Rust crate has no interceptors, upload progress or WebSockets ([Rust usage](#using-it-from-rust)).
105
+
106
+ ### One client for HTTP and WebSockets
107
+
108
+ Real-time features usually mean a second client with its own URL handling, timeouts and error handling. In rust-fetch, `client.websocket()` is part of the same client: it resolves against the same `baseUrl`, uses the same timeout for each connection attempt, and fails with the same typed errors as requests. It also adds what the platform's `WebSocket` lacks: reconnection with backoff after unexpected drops, a connect timeout, `opened`/`closed` promises and JSON messages ([WebSockets](#websockets)).
109
+
110
+ ### Compared with `undici.request()`
111
+
112
+ `undici.request()`, the engine under Node's `fetch()`, is a lower-level, Node.js-only API. It was faster than both rust-fetch transports for small requests over plain HTTP in our tests, so it's the better choice when raw throughput is all you need and you'll build error handling, timeouts and retries yourself. rust-fetch is the better choice when you want that policy built in and the same client in browsers. Over HTTPS, the native transport matched `undici.request()` (30,452 against 30,546 requests per second), and on 8 MB bodies it was ahead (322 against 301).
113
+
114
+ ### When to choose something else
115
+
116
+ - **Every kilobyte counts in your browser bundle.** The WebAssembly module is about 198 KB brotli-compressed.
117
+ - **You only need raw throughput in Node.js** and will handle failures yourself: use `undici.request()` (see above).
118
+ - **Your Node.js code makes one request at a time** and you're considering the native transport for speed: it's about 6% slower there.
119
+
120
+ ## Features
121
+
122
+ Unless noted, each of these works the same in browsers, Web Workers and Node.js 20+.
123
+
124
+ **Reliability**
125
+ - **Typed errors.** Every rejection is a real `Error` with a `kind` (`HttpError`, `TimeoutError`, `NetworkError`, …) and details like `status`, `body` and `timeoutMs`, so error handling branches on data instead of parsing messages. [Errors](#errors)
126
+ - **Per-attempt timeouts** that cover the response body, not just the headers, so a server that stalls mid-body can't hang a request. The default is 30 s. [Timeouts](#timeouts)
127
+ - **Retries** with exponential backoff and jitter, so clients recovering at the same time don't hit a struggling server in lockstep. `Retry-After` is honoured, and POST/PATCH aren't retried unless you opt in, so a retry can't duplicate an order or a payment. [Retries](#retries)
128
+ - **Cancellation** with `AbortSignal`, which also stops pending retry delays and streamed bodies, so an abandoned request releases its resources. [Cancellation](#cancellation)
129
+ - **Interceptors** for requests and responses: add auth headers, refresh tokens and report errors in one place. [Interceptors](#interceptors)
130
+
131
+ **Requests and responses**
132
+ - **Request bodies** from strings, bytes, `Blob`/`File`, `FormData` (multipart with files), `URLSearchParams` or JSON, with the matching `Content-Type` set for you. [Request bodies](#request-bodies)
133
+ - **Response decoding** by `Content-Type` (including `application/*+json`), or forced to `json`, `text`, `binary` or `stream`. [Responses](#responses)
134
+ - **Streaming** responses as a `ReadableStream`, and **download progress** callbacks, for large downloads. [Streaming](#streaming)
135
+ - **Upload progress** callbacks, for progress bars on large uploads, which `fetch()` can't report. [Upload progress](#upload-progress)
136
+
137
+ **Real-time**
138
+ - **WebSockets on the same client**: the same base URL, timeouts and typed errors as your requests, plus reconnection with jittered backoff after unexpected drops. Browsers and Node.js 22+. [WebSockets](#websockets)
139
+
140
+ **Node.js**
141
+ - **A bounded connection pool**: up to 256 keep-alive connections per origin, so traffic bursts queue and reuse connections instead of exhausting local ports. [Connection pooling](#connection-pooling)
142
+ - **A native transport** (not in the npm package yet; build it from source) that moves connection work off the event loop for more throughput under concurrency, and steps aside automatically for custom agents and proxies. [Architecture](#architecture)
143
+
144
+ **Tooling and quality**
145
+ - **TypeScript** definitions for the whole API, and the same client [as a Rust library](#using-it-from-rust).
146
+ - **Tested** with 100% line and function coverage of the Rust core and of the Node.js transport, enforced in CI, and with the JS suites on Node.js 20, 22 and 24. [Development](#development-and-benchmarking)
147
+
148
+ ## Performance
149
+
150
+ All results are from local servers over loopback, so they compare each client's own overhead, not network performance. Percentages are relative throughput (requests per second), not latency. Two sources:
151
+
152
+ - **Our measurements** of the `node:http` transport that the npm package uses and of the native transport (not in the npm package yet), next to Node's `fetch()` and `undici.request()`. Apple M1 Pro, Node.js 22.13.
153
+ - **An [independent benchmark](https://github.com/aakjp/stress-test/blob/main/BENCHMARK.md)** of the published rust-fetch 2.1.0 against `fetch()` and axios. Apple M4 Pro, Node.js 22.15.
154
+
155
+ These results are about Node.js. In browsers, `fetch()` is the only way to make requests, so rust-fetch uses it and can't be faster than it; the difference is a fraction of a millisecond per request, which matters little once a request crosses a real network.
156
+
157
+ ### The npm package today
158
+
159
+ rust-fetch as published (2.1.x, `node:http` transport) against Node.js `fetch()`. The independent column is summarized from the [full report below](#independent-benchmark-of-rust-fetch-210); ours from the [all-client tables](#throughput-all-clients):
160
+
161
+ | Workload | Independent benchmark (2.1.0, M4 Pro) | Our measurements (M1 Pro) |
162
+ | --- | --- | --- |
163
+ | Steady 15,000 requests/s | p99 **11.2 ms against 450 ms**; slowest request 64 ms against 1.5 s; **202 MB against 635 MB** of memory (one run) | Independent harness: kept up (14,981/s against 13,026/s); p50 6.3 ms against 449 ms; p99 565 ms against 920 ms; event-loop delay 9.8 ms against 310 ms. Our harness: 12,955/s against 11,557/s, but a worse p99 (1.6 s against 1.2 s) |
164
+ | 50 to 500 requests in flight | **105% to 116%** throughput at every level | **107% to 114%**, with a lower p99 at every level |
165
+ | Overload (offered 40,000 and 50,000 requests/s) | Found a port-exhaustion bug in 2.1.0, fixed in 2.1.1 | **1.7×** and **1.4×** the requests delivered |
166
+ | 8 MB binary bodies | **115%** (faster in 10 of 10 runs) | **133%** |
167
+ | 0.6 MB JSON | **105%** (faster in 10 of 10 runs) | 95% |
168
+ | Small JSON, one at a time | 100% | 102% |
169
+ | Small JSON, 50 at a time | 91% | 105% (500 at a time: 112%) |
170
+ | HTTPS small JSON, 50 at a time | not tested | 92% |
171
+
172
+ The published client matches `fetch()` one request at a time, is ahead with large binary bodies and many requests in flight, and stays steadier under sustained load in tail latency and memory. It's behind on concurrent HTTPS, and in one of the two 15,000 requests/s harnesses its tail was worse.
173
+
174
+ #### Independent benchmark of rust-fetch 2.1.0
175
+
176
+ [aakjp/stress-test](https://github.com/aakjp/stress-test/blob/main/BENCHMARK.md) compared the published client, rust-fetch 2.1.0 (`node:http` transport), with Node's built-in `fetch()` and axios 1.20, on an Apple M4 Pro with Node.js 22.15, against a local HTTP/1.1 server without TLS or compression. The full report has its method and raw data.
177
+
178
+ **Throughput**, requests per second, median of 10 runs:
179
+
180
+ | Workload | `fetch()` | rust-fetch | axios | rust-fetch vs `fetch()` |
181
+ | --- | ---: | ---: | ---: | ---: |
182
+ | Small JSON, one at a time | 9,649 | 9,680 | 7,497 | 100% |
183
+ | Small JSON, 50 at a time | 15,529 | 14,191 | 9,902 | 91% |
184
+ | Large JSON (0.6 MB) | 426 | 446 | 407 | **105%** (faster in 10 of 10 runs) |
185
+ | Binary (8 MB) | 174 | 201 | 188 | **115%** (faster in 10 of 10 runs) |
186
+
187
+ With each client in its own process and a constant number of requests in flight, rust-fetch was ahead of `fetch()` at every level (one run per level):
188
+
189
+ | Requests in flight | 50 | 100 | 200 | 500 |
190
+ | --- | ---: | ---: | ---: | ---: |
191
+ | `fetch()` | 22,800 | 22,390 | 21,181 | 19,019 |
192
+ | rust-fetch | **23,977** | **23,882** | **23,454** | **22,064** |
193
+ | axios (default) | 14,737 | 14,519 | 13,449 | 12,461 |
194
+
195
+ **A steady 15,000 requests per second for 60 seconds** (one run):
196
+
197
+ | Client | Delivered | Errors | p50 | p99 | Slowest | Peak memory |
198
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
199
+ | rust-fetch | 15,001/s | 0 | 4.7 ms | **11.2 ms** | **64 ms** | **202 MB** |
200
+ | `fetch()` | 14,944/s | 0 | **3.1 ms** | 450 ms | 1,545 ms | 635 MB |
201
+ | axios (keep-alive agent, 128 sockets) | 14,578/s | 0 | 669 ms | 718 ms | 731 ms | 761 MB |
202
+ | axios (default) | 3,292/s | 165,440 | 2.4 s | 13 s | 14 s | 843 MB |
203
+
204
+ `fetch()` had the fastest median, but its slowest requests took up to 1.5 s, and its tail varied tenfold between runs. axios's default configuration opens a new connection per request and ran out of local ports. The report's 15,000 requests per second was 89% of what its server could handle.
205
+
206
+ **Overload at 50,000 requests per second**, two to three times what any client could sustain on the M4 Pro, found a bug: rust-fetch 2.1.0 opened a new connection for every waiting request, ran out of local ports and delivered 3.2k requests per second, while `fetch()` delivered 14.4k. 2.1.1 fixed it with a pool of up to 256 connections per origin, so extra requests wait instead. In our re-run of that test on an Apple M1 Pro (Node.js 22.13), rust-fetch went from 2.7k requests per second and 43.5k errors to 18.9k and none.
207
+
208
+ The report tested three clients on one machine. It didn't include `undici.request()`, got or ky.
209
+
210
+ ### The native transport (not in the npm package yet)
211
+
212
+ The native Node.js transport against Node.js `fetch()`, from [our measurements](#our-measurements-every-client) (Apple M1 Pro, Node.js 22.13). The npm package doesn't include it yet; it has to be [built from source](#architecture).
213
+
214
+ | Workload | Native transport vs `fetch()` |
215
+ | --- | --- |
216
+ | HTTPS, 50 concurrent small-JSON requests | **1.95×** throughput |
217
+ | 500 concurrent small-JSON requests | **1.89×** throughput |
218
+ | 50 concurrent small-JSON requests | **1.71×** throughput |
219
+ | 8 MB binary transfers | **1.61×** throughput |
220
+ | Offered 40,000 requests/s (overload) | **2.9×** requests delivered |
221
+ | 50 to 500 requests in flight, independent harness | **1.6× to 1.7×** throughput, about **half the p99** at every level |
222
+ | Event loop at 15,000 requests/s, independent harness | **1.6 ms** p95 delay against 310 ms, leaving the event loop free for your application |
223
+ | Steady 15,000 requests/s, our harness | Kept up (14,992/s against 11,557/s), about **13× lower p99** (91 ms against 1.2 s) and about **4× less memory** (195 MB against 782 MB) |
224
+ | Steady 15,000 requests/s, independent harness | Kept up (14,991/s against 13,026/s); p99 568 ms against 920 ms; 541 MB against 903 MB |
225
+ | Large JSON, 0.6 MB | 1.02×, even |
226
+ | Sequential small-JSON requests | 0.94×, about 6% slower |
227
+
228
+ **Why concurrency is where it wins.** With the `node:http` transport, connection handling, HTTP parsing and decompression share the JavaScript event loop with your application. The native transport runs them on its own threads, leaving the event loop free to build requests and decode responses. With one request at a time there's little to overlap, and the hand-off between threads costs a little. How much you gain depends on your hardware, payloads and concurrency.
229
+
230
+ **Where it doesn't win:** sequential requests are about 6% slower than with `fetch()` (and than with the node:http transport); large JSON is effectively even (304 against 299 requests per second), and both parse the JSON in JavaScript; and under heavy overload it used about as much memory as `fetch()` (796 MB against 781 MB).
231
+
232
+ ### Our measurements: every client
233
+
234
+ The tables behind both summaries: Node's `fetch()`, `undici.request()` and both rust-fetch transports, measured together on an Apple M1 Pro with Node.js 22.13.
235
+
236
+ #### Throughput: all clients
237
+
238
+ All four clients ran in the same rounds, in rotating order, against a server in a separate process (HTTP/1.1, and HTTPS where noted). Requests per second, median of 7 rounds:
239
+
240
+ | Workload | `fetch()` | `undici.request()` | rust-fetch, node:http | rust-fetch, native | Native vs `fetch()` |
241
+ | --- | ---: | ---: | ---: | ---: | ---: |
242
+ | Small JSON, sequential | 10,468 | 15,346 | 10,691 | 9,857 | 94% (6% slower) |
243
+ | Small JSON, 50 concurrent | 18,019 | 42,260 | 18,916 | 30,874 | **171%** |
244
+ | Small JSON, 500 concurrent | 15,419 | 38,171 | 17,285 | 29,209 | **189%** |
245
+ | HTTPS small JSON, 50 concurrent | 15,639 | 30,546 | 14,444 | 30,452 | **195%** |
246
+ | Large JSON, 0.6 MB | 299 | 325 | 283 | 304 | 102% |
247
+ | Binary, 8 MB | 201 | 301 | 267 | 322 | **161%** |
248
+
249
+ In the three concurrent workloads, the native transport's p99 latency was less than half of `fetch()`'s (50 concurrent: 2.41 ms against 5.38 ms; 500 concurrent: 23.45 ms against 58.64 ms; HTTPS: 2.44 ms against 6.46 ms). For large JSON it was higher (8.76 ms against 5.39 ms).
250
+
251
+ #### Under load: all clients
252
+
253
+ Each client ran in its own process. These rows come from two different harnesses and aren't comparable with each other:
254
+
255
+ | Test | `fetch()` | `undici.request()` | rust-fetch, node:http | rust-fetch, native | Native vs `fetch()` |
256
+ | --- | ---: | ---: | ---: | ---: | ---: |
257
+ | Offered 40,000 requests/s for 20 s, delivered (our harness) | 10,081/s | 37,687/s | 16,984/s | 29,502/s | **2.9×** |
258
+ | Offered 50,000 requests/s for 60 s, delivered ([aakjp/stress-test](https://github.com/aakjp/stress-test) harness) | 12,421/s | not tested | 17,828/s | 32,678/s | **2.6×** |
259
+
260
+ **Concurrency sweep**, a constant number of requests in flight for 10 s each ([aakjp/stress-test](https://github.com/aakjp/stress-test) harness), requests per second with p99 latency:
261
+
262
+ | Requests in flight | `fetch()` | rust-fetch, node:http | rust-fetch, native | Native vs `fetch()` |
263
+ | --- | ---: | ---: | ---: | ---: |
264
+ | 50 | 17,041 (6.8 ms) | 18,452 (6.1 ms) | **29,198 (2.4 ms)** | **171%** |
265
+ | 100 | 17,660 (10.2 ms) | 18,855 (9.3 ms) | **28,402 (5.1 ms)** | **161%** |
266
+ | 200 | 16,365 (24.3 ms) | 18,624 (14.8 ms) | **28,454 (11.3 ms)** | **174%** |
267
+ | 500 | 15,960 (47.3 ms) | 17,685 (38.7 ms) | **27,563 (24.6 ms)** | **173%** |
268
+
269
+ At 40,000 requests per second, peak memory was 781 MB for `fetch()`, 244 MB for `undici.request()`, 567 MB for the node:http transport and 796 MB for the native transport, whose queued requests wait inside the addon.
270
+
271
+ **A steady 15,000 requests per second**, sent on a fixed schedule whether or not the client keeps up. No client had errors. Our harness (20 s, one server process):
272
+
273
+ | Client | Delivered | p50 | p95 | p99 | Peak memory |
274
+ | --- | ---: | ---: | ---: | ---: | ---: |
275
+ | `fetch()` | 11,557/s | 477 ms | 779 ms | 1.2 s | 782 MB |
276
+ | `undici.request()` | 14,992/s | 2.4 ms | 11 ms | 31 ms | 135 MB |
277
+ | rust-fetch, node:http | 12,955/s | 404 ms | 1.1 s | 1.6 s | 541 MB |
278
+ | rust-fetch, native | **14,992/s** | 4.1 ms | 46 ms | **91 ms** | **195 MB** |
279
+
280
+ The [aakjp/stress-test](https://github.com/aakjp/stress-test) harness (60 s, six server processes):
281
+
282
+ | Client | Delivered | p50 | p95 | p99 | Peak memory | JS heap after the run | Event-loop delay (p95) |
283
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
284
+ | `fetch()` | 13,026/s | 449 ms | 491 ms | 920 ms | 903 MB | 479 MB | 310 ms |
285
+ | rust-fetch, node:http | 14,981/s | 6.3 ms | 118 ms | 565 ms | 548 MB | 82 MB | 9.8 ms |
286
+ | rust-fetch, native | **14,991/s** | **3.5 ms** | **4.8 ms** | 568 ms | 541 MB | **23 MB** | **1.6 ms** |
287
+
288
+ Event-loop delay is how late the harness's 10 ms scheduler ran: how long your own code would wait behind the HTTP client.
289
+
290
+ In both harnesses, the native transport sustained approximately the target rate and `fetch()` didn't. In the independent harness, both rust-fetch transports still show a p99 stall of about 565 ms even though the native transport's p95 was 4.8 ms. Its cause hasn't been verified.
291
+
292
+ ### Reading these results
293
+
294
+ - **Throughput, latency and memory are separate questions.** A client can deliver more requests per second with a worse tail, or the reverse; memory is the peak resident size of the client process.
295
+ - **`fetch()`, `undici.request()` and rust-fetch do different amounts of work.** Node's `fetch()` implements the browser `fetch()` API on top of undici. `undici.request()` is undici's lower-level API, without WHATWG streams. rust-fetch adds retries, per-attempt timeouts, validation, typed errors and interceptors to every request. So `undici.request()` isn't an identical workload, and it was fastest on plain HTTP in these tests: 1.4 to 2.2 times the node:http transport's small-JSON throughput and 1.3 to 1.6 times the native transport's, while over HTTPS the native transport matched it.
296
+ - **Results vary** with hardware, Node.js version, payload size, concurrency and the server setup. The node:http transport delivered 12,955 requests per second at an offered 15,000 in one harness and 14,981 in the other, so measure on your own hardware before relying on a number ([how](#benchmarks)).
297
+
298
+ ## Usage and configuration
299
+
300
+ The examples in this section use a client like this one:
301
+
302
+ ```js
303
+ import { WasmClient } from 'rust-fetch';
304
+
305
+ const client = new WasmClient({ baseUrl: 'https://api.example.com/v1' });
306
+ ```
78
307
 
79
308
  ### Client options
80
309
 
@@ -117,6 +346,7 @@ Relative URLs need a `baseUrl`. In a browser page without one, they resolve agai
117
346
  | `responseType` | `'auto'` \| `'json'` \| `'text'` \| `'binary'` \| `'stream'` | See [Responses](#responses) |
118
347
  | `signal` | `AbortSignal` | See [Cancellation](#cancellation) |
119
348
  | `onDownloadProgress` | `({ loaded, total }) => void` | See [Download progress](#download-progress) |
349
+ | `onUploadProgress` | `({ loaded, total }) => void` | See [Upload progress](#upload-progress) |
120
350
 
121
351
  ### Request bodies
122
352
 
@@ -164,7 +394,7 @@ Every rejection is an `Error` whose `name` and `kind` are one of:
164
394
  | --- | --- | --- |
165
395
  | `HttpError` | Non-2xx response | `status`, `statusText`, `body` (full response body as text) |
166
396
  | `TimeoutError` | An attempt exceeded `timeoutMs` | `timeoutMs` |
167
- | `NetworkError` | DNS, connection, TLS or CORS failure, or a body cut off mid-transfer | |
397
+ | `NetworkError` | DNS, connection, TLS or CORS failure, a body cut off mid-transfer, or a WebSocket that fails to connect or drops | |
168
398
  | `CancelledError` | The `signal` was aborted | |
169
399
  | `InvalidInputError` | Bad URL, method, header, option or body. Rejected before anything is sent. | `parameter` |
170
400
  | `ParseError` | `responseType: 'json'` and the body isn't JSON | |
@@ -212,28 +442,65 @@ new WasmClient({
212
442
  - A `Retry-After` header in seconds is honoured, capped at `maxDelayMs`.
213
443
  - POST and PATCH aren't retried by default. The server may already have acted on a failed attempt, so retrying can duplicate work.
214
444
  - `InvalidInputError`, `ParseError` and unlisted statuses are never retried.
445
+ - When every attempt fails, the request rejects with the last attempt's error.
215
446
 
216
447
  ### Cancellation
217
448
 
218
449
  ```js
219
450
  const controller = new AbortController();
220
- const promise = client.get('/report', { signal: controller.signal });
221
- controller.abort(); // rejects with a CancelledError
451
+ const request = client.get('/report', { signal: controller.signal });
452
+ controller.abort();
453
+ try {
454
+ await request;
455
+ } catch (error) {
456
+ console.log(error.kind); // 'CancelledError'
457
+ }
222
458
 
223
459
  await client.get('/report', { signal: AbortSignal.timeout(5_000) }); // total deadline, including retries
224
460
  ```
225
461
 
226
462
  Aborting cancels the request in flight, any pending retry delay, and a body being streamed.
227
463
 
464
+ ### Interceptors
465
+
466
+ ```js
467
+ const token = 'your-access-token'; // e.g. from your auth flow
468
+
469
+ // Add a header to every request (may be async)
470
+ client.addRequestInterceptor((request) => {
471
+ request.headers.authorization = `Bearer ${token}`;
472
+ // mutate in place, or return a new { method, url, headers, body }
473
+ });
474
+
475
+ // Resolve to the body instead of the whole response
476
+ client.addResponseInterceptor((response) => response.body);
477
+
478
+ // Report failures, then keep rejecting
479
+ client.addResponseInterceptor(undefined, (error) => {
480
+ console.error('request failed:', error.kind, error.message);
481
+ throw error;
482
+ });
483
+ ```
484
+
485
+ An `onRejected` handler can also recover by returning a value, for example the result of retrying the request after refreshing a token.
486
+
487
+ - 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.
488
+ - Response interceptors chain like `promise.then(onFulfilled, onRejected)`. Their return values are what the call resolves to. Throw to reject.
489
+ - `addRequestInterceptor` and `addResponseInterceptor` return an id for `ejectInterceptor(id)`. `clearInterceptors()` removes all of them.
490
+ - A request uses the interceptors registered when it started.
491
+ - Interceptors apply to HTTP requests, not to WebSockets.
492
+
228
493
  ### Streaming
229
494
 
230
495
  `responseType: 'stream'` resolves as soon as the headers arrive. `body` is then a `ReadableStream<Uint8Array>`:
231
496
 
232
497
  ```js
233
498
  const res = await client.get('/export.csv', { responseType: 'stream' });
499
+ let received = 0;
234
500
  for await (const chunk of res.body) {
235
- process(chunk); // Uint8Array
501
+ received += chunk.byteLength; // each chunk is a Uint8Array
236
502
  }
503
+ console.log(`${received} bytes`);
237
504
  ```
238
505
 
239
506
  `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`.
@@ -251,8 +518,6 @@ await client.get('/video.mp4', {
251
518
 
252
519
  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
520
 
254
- Upload progress isn't available, because `fetch()` doesn't report it.
255
-
256
521
  ### File uploads
257
522
 
258
523
  Pass a `FormData` and it's sent as `multipart/form-data`, files included:
@@ -260,50 +525,95 @@ Pass a `FormData` and it's sent as `multipart/form-data`, files included:
260
525
  ```js
261
526
  const form = new FormData();
262
527
  form.append('title', 'Quarterly report');
263
- form.append('file', fileInput.files[0]); // or new File([bytes], 'report.pdf', { type: 'application/pdf' })
528
+ form.append('file', new File(['%PDF-1.7 …'], 'report.pdf', { type: 'application/pdf' })); // or a file from <input type="file">
264
529
  await client.post('/upload', { body: form });
265
530
  ```
266
531
 
267
532
  A single `Blob` or `File` body is sent as raw bytes with the blob's type.
268
533
 
269
- ### Interceptors
534
+ ### Upload progress
535
+
536
+ Report how much of a request body has been sent, for example to drive a progress bar:
270
537
 
271
538
  ```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 }
539
+ const form = new FormData();
540
+ form.append('video', new Blob([new Uint8Array(50_000_000)]), 'video.mp4');
541
+
542
+ await client.post('/upload', {
543
+ body: form,
544
+ onUploadProgress: ({ loaded, total }) => {
545
+ console.log(`${Math.round((loaded / total) * 100)}% uploaded`);
546
+ },
276
547
  });
548
+ ```
277
549
 
278
- // Resolve to the body instead of the whole response
279
- client.addResponseInterceptor((response) => response.body);
550
+ `loaded` is the number of body bytes sent so far and `total` is the body's size. A retried request starts again from 0. Exceptions thrown by the callback are ignored, and requests without a body report nothing. It works with `onDownloadProgress` on the same request, but not with `responseType: 'stream'`.
280
551
 
281
- // Report failures, then keep rejecting
282
- client.addResponseInterceptor(undefined, (error) => {
283
- reportError(error);
284
- throw error;
552
+ How it's sent:
553
+ - **Browsers:** `fetch()` has no upload progress events, so these requests go through `XMLHttpRequest`, with the same redirects, credentials, timeouts, retries and errors. Like any `XMLHttpRequest` with an upload listener, a cross-origin request is always [preflighted](https://developer.mozilla.org/docs/Web/HTTP/CORS#preflighted_requests), so the server must answer `OPTIONS`. Where `XMLHttpRequest` doesn't exist (service workers), progress is reported once, when the response arrives.
554
+ - **Node.js:** the `node:http` transport (never the native one) writes the body in 64 KB chunks and reports each once it has been handed to the operating system.
555
+
556
+ ### WebSockets
557
+
558
+ Available in browsers and in Node.js 22 and newer (Node.js 20 needs `--experimental-websocket`).
559
+
560
+ ```js
561
+ const socket = client.websocket('/live', { // wss://api.example.com/v1/live?room=blue
562
+ params: { room: 'blue' },
563
+ messageType: 'json', // parse text messages as JSON
564
+ reconnect: true, // after failures and unexpected drops, 1 s doubling to 30 s
565
+ onMessage: (message) => console.log('received', message),
566
+ onError: (error) => console.warn(error.kind, error.message),
567
+ onReconnect: ({ attempt, delayMs }) => console.log(`reconnecting in ${delayMs} ms (attempt ${attempt})`),
285
568
  });
569
+
570
+ await socket.opened; // rejects with a typed error if it never connects
571
+ socket.sendJson({ type: 'subscribe', channel: 'prices' });
572
+ socket.close(); // stops reconnecting
573
+ const { code, reason, wasClean } = await socket.closed;
286
574
  ```
287
575
 
288
- An `onRejected` handler can also recover by returning a value, for example the result of retrying the request after refreshing a token.
576
+ In Node.js you can authenticate the handshake with headers, which browsers don't allow:
289
577
 
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.
578
+ ```js
579
+ const authed = client.websocket('/live', { headers: { authorization: 'Bearer your-access-token' } }); // Node.js only
580
+ ```
581
+
582
+ `websocket()` resolves relative URLs against `baseUrl`, turning `http:` into `ws:` and `https:` into `wss:`, and returns at once; messages sent before the connection opens (or while it reconnects) are queued. `state` is `'connecting'`, `'open'`, `'reconnecting'`, `'closing'` or `'closed'`.
583
+
584
+ | Option | Description |
585
+ | --- | --- |
586
+ | `protocols` | Subprotocols to offer; the chosen one is `socket.protocol` |
587
+ | `params` | Appended to the query string |
588
+ | `headers` | Handshake headers, merged over the client's. Node.js only: browsers don't allow WebSocket headers, so passing them there throws an `InvalidInputError`, and the client's headers aren't sent |
589
+ | `timeoutMs` | Time allowed for each connection attempt; defaults to the client's `timeoutMs`, `0` disables it |
590
+ | `reconnect` | `true`, a maximum number of attempts in a row, or `{ maxRetries, initialDelayMs, maxDelayMs, multiplier }` (defaults: unlimited, 1000, 30000, 2). Off by default |
591
+ | `messageType` | `'auto'` (default: text as strings, binary as `Uint8Array`) or `'json'` |
592
+ | `signal` | Closes the connection and stops reconnecting when aborted |
593
+ | `onOpen`, `onMessage`, `onError`, `onReconnect`, `onClose` | Callbacks; `onClose` runs once, when the connection is closed for good |
594
+
595
+ Failures are typed like request errors: a refused or failed connection is a `NetworkError`, an attempt that takes too long a `TimeoutError`, a JSON message that doesn't parse a `ParseError`, and closing before the first connection opens rejects `opened` with a `CancelledError`. With `reconnect`, a failed attempt or an unexpected drop (close codes 1001, 1006 and 1011–1014) is retried with jittered exponential backoff, and the count resets once a connection opens. A server that closes normally (1000) or with its own code isn't reconnected to. An exception thrown by a callback doesn't affect the connection; it's reported as an unhandled promise rejection. As with an exception in a handler of the platform's own `WebSocket`, Node.js then ends the process unless you handle it (`process.on('unhandledRejection', …)`), so catch errors inside your callbacks.
596
+
597
+ `send()` takes a string, `ArrayBuffer`, typed array, `DataView` or `Blob`; `sendJson()` takes any JSON value. `close(code, reason)` takes 1000 (the default) or 3000–4999.
294
598
 
295
599
  ### TypeScript
296
600
 
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`.
601
+ 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`, `UploadProgress` and `InterceptedRequest`, and for WebSockets `WebSocketConnection`, `WebSocketOptions`, `ReconnectOptions`, `WebSocketCloseInfo`, `WebSocketReconnectInfo`, `WebSocketState` and `WebSocketData`.
602
+
603
+ Every request method takes the response body's type as a type parameter, defaulting to `any`:
298
604
 
299
605
  ```ts
300
606
  import { WasmClient, type HttpResponse, type RustFetchError } from 'rust-fetch';
301
607
 
608
+ const client = new WasmClient({ baseUrl: 'https://api.example.com/v1' });
609
+
302
610
  interface User { id: number; name: string }
303
611
 
304
- const res: HttpResponse<User> = await client.get('/users/42');
612
+ const res = await client.get<User>('/users/42'); // HttpResponse<User>
305
613
  res.body.name; // string
306
614
 
615
+ const created: HttpResponse<User> = await client.post<User>('/users', { body: { name: 'Zoë' } });
616
+
307
617
  try {
308
618
  await client.get('/users/9');
309
619
  } catch (e) {
@@ -312,7 +622,9 @@ try {
312
622
  }
313
623
  ```
314
624
 
315
- ## Legacy functions
625
+ The type parameter describes what you expect; rust-fetch doesn't check the JSON against it. Validate untrusted responses with a schema library if you need to.
626
+
627
+ ### Legacy functions
316
628
 
317
629
  These standalone functions still work. Each call uses a default client: 30 s timeout, no retries.
318
630
 
@@ -348,44 +660,59 @@ client.post("upload").multipart(&form).send().await?;
348
660
  let mut chunks = client.get_streaming("export.csv").await?.into_stream(); // a futures::Stream of Vec<u8>
349
661
  ```
350
662
 
351
- Request futures are `Send`, so you can `tokio::spawn` them. Progress callbacks (`on_download_progress`) must be `Send + Sync` on native targets.
663
+ Request futures are `Send`, so you can `tokio::spawn` them. Progress callbacks (`on_download_progress`) must be `Send + Sync` on native targets. Upload progress, WebSockets and interceptors are only in the JavaScript API.
352
664
 
353
- ## Performance
665
+ ## Architecture
666
+
667
+ Everything that decides what happens to a request (URL handling, validation, retries and backoff, per-attempt timeouts, typed errors, interceptors) is Rust compiled to WebAssembly, so it behaves the same in every runtime. Underneath, a transport only moves bytes:
668
+
669
+ | Transport | Where | In the npm package |
670
+ | --- | --- | --- |
671
+ | The platform's `fetch()` | Browsers and Web Workers | Yes |
672
+ | `XMLHttpRequest` | Browsers, for upload progress | Yes |
673
+ | `node:http` / `node:https` with keep-alive | Node.js | Yes |
674
+ | The runtime's `WebSocket`, with the connection policy (URL, timeout, reconnection, errors) in Rust | WebSockets, everywhere | Yes |
675
+ | Native addon: HTTP/1.1 on [hyper](https://hyper.rs) and Tokio threads, loaded through Node-API, with the same redirect, decompression, timeout and connection-limit rules and Node's TLS defaults (Mozilla root certificates, `NODE_EXTRA_CA_CERTS`, `NODE_TLS_REJECT_UNAUTHORIZED`) | Node.js | Not yet: build it with `npm run build:native` |
354
676
 
355
- **In Node.js, rust-fetch is as fast as or faster than the built-in `fetch()` for typical API traffic.** Node's `fetch()` implements browser semantics (WHATWG streams) on top of its sockets. rust-fetch uses its own lean transport on `node:http`/`node:https` with keep-alive. Retries, timeouts, validation and errors still come from the Rust core.
677
+ **Which transport a request uses:**
356
678
 
357
- `npm run bench` (Node 22.13, Apple M1 Pro, local server, median of 5 alternating rounds):
679
+ | Request | Browsers | Node.js |
680
+ | --- | --- | --- |
681
+ | Most requests | `fetch()` | The native transport where it's built and Node's global agents are untouched; otherwise `node:http` |
682
+ | With `onUploadProgress` (with or without `onDownloadProgress`) | `XMLHttpRequest`, or `fetch()` where there's none | `node:http` |
683
+ | With `onDownloadProgress` only, or `responseType: 'stream'` | `fetch()` | Node's `fetch()` |
684
+
685
+ **What the native transport changes.** Connection handling, TLS, HTTP parsing and decompression move from the JavaScript event loop to Tokio threads; JavaScript only builds requests and decodes their bodies. That's why it gains most with many requests in flight, and why the hand-off between threads likely contributes to its slower sequential results ([measurements](#the-native-transport-not-in-the-npm-package-yet)).
686
+
687
+ **When it's used.** The native transport is used when `rust-fetch-native.<platform>-<arch>.node` sits next to the package's `node.js` (`npm run build:native` puts it there). It steps aside where you have installed your own `http.globalAgent` / `https.globalAgent` (for example a proxy agent) or set `NODE_USE_ENV_PROXY`, and `RUST_FETCH_NATIVE=0` turns it off.
358
688
 
359
- | Scenario | native `fetch` | rust-fetch | rust-fetch / native |
360
- | --- | ---: | ---: | ---: |
361
- | Small JSON, sequential | 10,558 req/s | 10,634 req/s | **101%** |
362
- | Small JSON, 50 concurrent | 13,401 req/s | 13,895 req/s | **104%** |
363
- | JSON 0.6 MB | 219 req/s | 203 req/s | 92% |
364
- | Binary 8 MB | 177 req/s | 181 req/s | **102%** |
689
+ ### Connection pooling
365
690
 
366
- Large JSON is dominated by `JSON.parse`, which both clients pay; the remaining gap is extra garbage collection from crossing into Wasm.
691
+ Browsers pool connections for `fetch()`. In Node.js, both transports keep up to 256 keep-alive connections per origin and queue further requests until one is free, so bursts reuse connections instead of exhausting local ports. If you install your own `http.globalAgent` / `https.globalAgent`, or set limits on Node's, that agent is used instead.
367
692
 
368
- **In browsers**, `fetch()` is the only way to make requests, so rust-fetch uses it and can't be faster than it. The difference is a fraction of a millisecond per request, which matters little once a request crosses a real network.
693
+ ## Compatibility and limitations
694
+
695
+ ### Supported environments
696
+
697
+ | Environment | Status |
698
+ | --- | --- |
699
+ | Browsers and Web Workers | Supported, directly or through bundlers like Vite and webpack; call `init()` first |
700
+ | Node.js 20, 22 and 24 | Supported and tested in CI; Node.js 20 is the minimum |
701
+ | TypeScript | Definitions included; TypeScript 5.2 or newer |
702
+ | Upload progress, WebSockets | JavaScript API only, from 2.2.0. WebSockets need a built-in `WebSocket`: browsers, Node.js 22+, or Node.js 20 with `--experimental-websocket`. Tested in CI on Node.js 22 and 24 |
703
+ | Rust | The same client as a Rust library ([usage](#using-it-from-rust)), on tokio or in your own wasm crates |
704
+ | Native Node.js transport | In the repository, not yet in the npm package (no prebuilt binaries); build it with `npm run build:native`. Tested in CI on Linux with Node.js 20, 22 and 24, and run on macOS (Apple silicon); not tried on Windows |
369
705
 
370
- ## Roadmap
706
+ ### Limitations
371
707
 
372
- - [x] POST, PUT, PATCH, DELETE, HEAD, OPTIONS
373
- - [x] Request header customization
374
- - [x] Comprehensive test suite: Rust unit and integration tests (100% line coverage, enforced in CI), wasm tests, and JS end-to-end tests
375
- - [x] Streaming responses (`responseType: 'stream'`; `send_streaming()` in Rust)
376
- - [x] Request cancellation (`AbortSignal`)
377
- - [x] Performance benchmarks against native fetch (`npm run bench`)
378
- - [x] Multipart form data and file uploads
379
- - [x] Request/response interceptors
380
- - [x] Download progress tracking
381
- - [ ] Upload progress tracking: not possible on top of `fetch()`, which has no upload progress events
382
- - [ ] WebSocket support
383
- - Handled by the runtime, not by rust-fetch:
384
- - **Connection pooling:** browsers pool connections for `fetch()`; in Node.js rust-fetch reuses connections through Node's default keep-alive agents.
385
- - **Proxies:** in Node.js, install a proxy agent as `http.globalAgent` / `https.globalAgent` (for example from `https-proxy-agent`). In browsers, use the system settings.
386
- - **HTTP/2:** browsers negotiate it automatically; the Node.js transport uses HTTP/1.1 with keep-alive.
708
+ - **Bundle size:** the WebAssembly module adds about 198 KB (brotli) to a browser bundle.
709
+ - **HTTP/2:** browsers negotiate it automatically; both Node.js transports use HTTP/1.1 with keep-alive.
710
+ - **Proxies:** in Node.js, install a proxy agent as `http.globalAgent` / `https.globalAgent` (for example from `https-proxy-agent`). In browsers, use the system settings.
711
+ - **Native transport:** has to be built from source for each platform until it's published. It's about 6% slower than `fetch()` for sequential requests, isn't used for streamed responses or requests with progress callbacks, and the p99 stall seen in the independent harness is unexplained (see [Under load](#under-load-all-clients)).
712
+ - **WebSockets** use the runtime's own `WebSocket`: built into browsers and Node.js 22 and newer, and into Node.js 20 only with `--experimental-websocket` (without it, `websocket()` throws a `NetworkError`). In Node.js it doesn't go through `http.globalAgent`, so proxy agents installed there don't apply.
713
+ - **Upload progress** in browsers needs `XMLHttpRequest`, which makes cross-origin requests preflighted (see [Upload progress](#upload-progress)).
387
714
 
388
- ## Upgrading to 2.0
715
+ ### Upgrading to 2.0
389
716
 
390
717
  2.0 fixes long-standing bugs, and some code may have depended on the old behaviour. Check these points:
391
718
 
@@ -411,12 +738,13 @@ Rust crate users:
411
738
 
412
739
  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`).
413
740
 
414
- ## Development
741
+ ## Development and benchmarking
415
742
 
416
- The npm package is built from `wasm-module/` with [wasm-pack](https://rustwasm.github.io/wasm-pack/). The repository root is a private workspace.
743
+ The npm package is built from `wasm-module/` with [wasm-pack](https://rustwasm.github.io/wasm-pack/); the native transport is built from `native/`. The repository root is a private workspace.
417
744
 
418
745
  ```bash
419
746
  npm run build # wasm-pack build + package metadata (wasm-module/pkg)
747
+ npm run build:native # build the native Node.js transport (native/) into wasm-module/pkg
420
748
  npm test # Rust unit + integration tests, then the JS tests
421
749
  npm run test:js # JS end-to-end tests against the built package (requires a build)
422
750
  npm run bench # rust-fetch vs native fetch (requires a build)
@@ -435,14 +763,45 @@ cargo llvm-cov --ignore-filename-regex 'src/(wasm/|http\.rs)|tests/' # coverag
435
763
  | 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 |
436
764
  | Wasm unit tests | `wasm-module/src/wasm/tests.rs` | JS conversions, bodies, options, interceptors, timers and abort signals in Node.js |
437
765
  | JS end-to-end tests | `wasm-module/js/test/client.test.mjs` | The built package against a local server |
766
+ | Node transport tests | `wasm-module/js/test/node-transport.test.mjs` | Redirects, decompression, default headers, connection pooling and HTTPS. With `client.test.mjs`, they run once per Node.js transport: native (when built) and node:http (the `*-js.test.mjs` files) |
767
+ | Browser transport tests | `wasm-module/js/test/browser-transport.test.mjs` | The browser entry without the Node.js transport: `fetch()`, and `XMLHttpRequest` (simulated over `node:http`) for upload progress |
768
+ | WebSocket tests | `wasm-module/js/test/websocket.test.mjs` | `websocket()` against a local WebSocket server: URLs, headers, messages, timeouts, reconnection, closing |
438
769
  | 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 |
439
770
 
440
- CI runs the Rust suites with a coverage gate that fails if line or function coverage of the Rust core, or of the Node.js transport, 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.
771
+ CI runs the Rust suites with a coverage gate that fails if line or function coverage of the Rust core, or of the Node.js transport, drops below 100%. It also runs the wasm and JS suites on Node.js 20, 22 and 24 (against both Node.js transports), and checks formatting, lints of the native transport, dependencies (security advisories and licenses), API compatibility and the size budget. 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.
441
772
 
442
- Releases are published by CI when a version tag is pushed. See [CONTRIBUTING.md](CONTRIBUTING.md#releasing).
773
+ ### Benchmarks
443
774
 
444
- ## License
775
+ `npm run bench` compares rust-fetch with Node's `fetch()` on a local server. It measures the native transport when it's built (`npm run build:native`); run it with `RUST_FETCH_NATIVE=0` to measure the node:http transport. For sustained load and overload, the [aakjp/stress-test](https://github.com/aakjp/stress-test) harness runs against any installed version of rust-fetch.
445
776
 
446
- Licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
777
+ ## FAQ
778
+
779
+ **How do I add retries and timeouts to `fetch()`?**
780
+ Use rust-fetch's client instead: `new WasmClient({ timeoutMs: 10_000, retries: 3 })` times out each attempt (including the body) and retries network errors, timeouts and 408/429/5xx responses with exponential backoff and jitter, honouring `Retry-After`. POST and PATCH aren't retried unless you opt in. See [Timeouts](#timeouts) and [Retries](#retries).
781
+
782
+ **Is rust-fetch an alternative to axios?**
783
+ Yes, for most uses: it rejects on HTTP errors, has interceptors, timeouts, upload and download progress, and adds built-in retries, typed errors and WebSockets, with the same client in browsers and Node.js. axios is much smaller in a browser bundle (about 21 KB against 207 KB brotli). See [Compared with `fetch()` and axios](#compared-with-fetch-and-axios).
784
+
785
+ **Does it work in browsers and Node.js?**
786
+ Both, with the same API: browsers and Web Workers (through bundlers such as Vite and webpack; call `init()` first) and Node.js 20, 22 and 24. TypeScript definitions are included. See [Supported environments](#supported-environments).
787
+
788
+ **Is it faster than `fetch()`?**
789
+ In Node.js, the published client matches `fetch()` one request at a time and is ahead with large bodies, many requests in flight and sustained load, but behind on concurrent HTTPS. The native transport, not in the npm package yet, reached up to 1.95× `fetch()`'s throughput. In browsers it uses `fetch()` itself. See [Performance](#performance).
790
+
791
+ **How do I track upload progress?**
792
+ Pass `onUploadProgress: ({ loaded, total }) => …` to `post`, `put` or `patch`. See [Upload progress](#upload-progress).
447
793
 
448
- Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development and release process, and [SECURITY.md](SECURITY.md) to report a vulnerability.
794
+ **How do I reconnect a WebSocket automatically?**
795
+ `client.websocket(url, { reconnect: true })` reconnects after failures and unexpected drops with exponential backoff, and reports failures as typed errors. See [WebSockets](#websockets).
796
+
797
+ **How do I type the response body in TypeScript?**
798
+ `client.get<User>('/users/42')` resolves to `HttpResponse<User>`. See [TypeScript](#typescript).
799
+
800
+ **Do I need to know Rust?**
801
+ No. The npm package is used from JavaScript or TypeScript like any other; Rust is only how the core is built. Rust developers can also [use the crate directly](#using-it-from-rust).
802
+
803
+ ## Contributing and license
804
+
805
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development process and pull request checks, and [SECURITY.md](SECURITY.md) to report a vulnerability. Releases are published by CI when a version tag is pushed; see [CONTRIBUTING.md](CONTRIBUTING.md#releasing). Planned work is in the [roadmap](ROADMAP.md).
806
+
807
+ Licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.