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/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +345 -167
- package/node.js +13 -0
- package/package.json +29 -5
- package/rust_fetch.d.ts +346 -68
- package/rust_fetch.js +1108 -730
- package/rust_fetch_bg.wasm +0 -0
- package/rust_fetch_bg.wasm.d.ts +54 -0
package/README.md
CHANGED
|
@@ -1,267 +1,445 @@
|
|
|
1
1
|
# rust-fetch
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/rust-fetch)
|
|
4
|
-
[](https://github.com/asemqaffaf/rust-fetch/actions/workflows/rust.yml)
|
|
5
|
+
[](https://github.com/asemqaffaf/rust-fetch/actions/workflows/rust.yml)
|
|
6
|
+
[](https://codecov.io/gh/asemqaffaf/rust-fetch)
|
|
7
|
+
[](#license)
|
|
5
8
|
[](https://webassembly.org/)
|
|
6
9
|
|
|
7
|
-
A
|
|
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
|
-
|
|
13
|
+
```js
|
|
14
|
+
import { WasmClient } from 'rust-fetch';
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
npm install rust-fetch
|
|
29
52
|
```
|
|
30
53
|
|
|
31
|
-
##
|
|
54
|
+
## Quick start
|
|
32
55
|
|
|
33
|
-
|
|
56
|
+
**Node.js** (20+): the WebAssembly module loads on import.
|
|
34
57
|
|
|
35
|
-
|
|
58
|
+
```js
|
|
59
|
+
import { WasmClient } from 'rust-fetch';
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
const data = { title: 'foo', body: 'bar', userId: 1 };
|
|
77
|
+
## Guide
|
|
60
78
|
|
|
61
|
-
|
|
62
|
-
.then(response => console.log(response))
|
|
63
|
-
.catch(error => console.error('Error:', error));
|
|
64
|
-
```
|
|
79
|
+
### Client options
|
|
65
80
|
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
import init, { fetch_json, fetch_text } from 'rust-fetch';
|
|
90
|
+
Invalid options throw an `InvalidInputError` right away, from the constructor.
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
await init();
|
|
92
|
+
### Making requests
|
|
73
93
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
You can specify HTTP method, headers, and body:
|
|
121
|
+
### Request bodies
|
|
97
122
|
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
Every rejection is an `Error` whose `name` and `kind` are one of:
|
|
121
162
|
|
|
122
|
-
|
|
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
|
-
```
|
|
173
|
+
```js
|
|
125
174
|
try {
|
|
126
|
-
|
|
127
|
-
console.log('Success:', data);
|
|
175
|
+
await client.get('/users/9');
|
|
128
176
|
} catch (error) {
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
189
|
+
### Timeouts
|
|
137
190
|
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
import { fetch_json } from 'rust-fetch';
|
|
193
|
+
### Retries
|
|
143
194
|
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
import { fetch_text } from 'rust-fetch';
|
|
216
|
+
### Cancellation
|
|
152
217
|
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
226
|
+
Aborting cancels the underlying `fetch`, any pending retry delay, and a body being streamed.
|
|
227
|
+
|
|
228
|
+
### Streaming
|
|
158
229
|
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
Creates a new HTTP client instance.
|
|
241
|
+
### Download progress
|
|
174
242
|
|
|
175
|
-
```
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
302
|
+
interface User { id: number; name: string }
|
|
204
303
|
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
+
## Legacy functions
|
|
214
316
|
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
+
## Using it from Rust
|
|
224
328
|
|
|
225
|
-
|
|
329
|
+
The same client is a Rust library, for native code (on tokio) and for your own wasm crates:
|
|
226
330
|
|
|
227
|
-
|
|
331
|
+
```rust
|
|
332
|
+
use rust_fetch::{Client, Multipart, types::RetryConfig};
|
|
333
|
+
use std::time::Duration;
|
|
228
334
|
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
+
let mut chunks = client.get_streaming("export.csv").await?.into_stream(); // a futures::Stream of Vec<u8>
|
|
349
|
+
```
|
|
238
350
|
|
|
239
|
-
|
|
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
|
-
|
|
353
|
+
## Performance
|
|
244
354
|
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- [
|
|
260
|
-
- [
|
|
261
|
-
- [
|
|
262
|
-
- [
|
|
263
|
-
- [
|
|
264
|
-
- [
|
|
265
|
-
- [
|
|
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
|
-
-
|
|
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';
|