rust-fetch 1.1.0 → 2.1.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 +305 -663
- package/node.js +265 -0
- package/package.json +29 -5
- package/rust_fetch.d.ts +346 -93
- package/rust_fetch.js +1157 -792
- package/rust_fetch_bg.wasm +0 -0
- package/rust_fetch_bg.wasm.d.ts +54 -0
package/node.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
//
|
|
8
|
+
// It also registers a transport built on node:http/node:https. Node's global
|
|
9
|
+
// fetch() implements browser semantics (WHATWG streams) on top of the same
|
|
10
|
+
// sockets and is considerably slower; this transport only moves bytes.
|
|
11
|
+
// Retries, timeouts, validation and decoding stay in the Rust core.
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import https from 'node:https';
|
|
15
|
+
import zlib from 'node:zlib';
|
|
16
|
+
import { initSync } from './rust_fetch.js';
|
|
17
|
+
|
|
18
|
+
initSync({ module: readFileSync(new URL('./rust_fetch_bg.wasm', import.meta.url)) });
|
|
19
|
+
|
|
20
|
+
const MAX_REDIRECTS = 20;
|
|
21
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
22
|
+
const ACCEPT_ENCODING = typeof zlib.zstdDecompressSync === 'function' ? 'gzip, deflate, br, zstd' : 'gzip, deflate, br';
|
|
23
|
+
|
|
24
|
+
const textDecoder = new TextDecoder();
|
|
25
|
+
// Response body types, numbered like `BodyType` in src/wasm/mod.rs
|
|
26
|
+
const AUTO = 0;
|
|
27
|
+
const JSON_BODY = 1;
|
|
28
|
+
const TEXT = 2;
|
|
29
|
+
const BINARY = 3;
|
|
30
|
+
|
|
31
|
+
// Same rules as is_json_content_type / is_text_content_type in src/types.rs
|
|
32
|
+
function mimeEssence(contentType) {
|
|
33
|
+
return (contentType ?? '').split(';')[0].trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
function isJsonType(essence) {
|
|
36
|
+
return essence === 'application/json' || essence.endsWith('+json');
|
|
37
|
+
}
|
|
38
|
+
function isTextType(essence) {
|
|
39
|
+
return (
|
|
40
|
+
essence.startsWith('text/') ||
|
|
41
|
+
essence.endsWith('+xml') ||
|
|
42
|
+
essence === 'application/xml' ||
|
|
43
|
+
essence === 'application/javascript' ||
|
|
44
|
+
essence === 'application/x-www-form-urlencoded'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function coded(error, code) {
|
|
49
|
+
error.code = code;
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decode a body like `decode_body` in src/wasm/transport.rs, in JS so the
|
|
55
|
+
* text never crosses into wasm. TextDecoder replaces invalid UTF-8 and strips
|
|
56
|
+
* a BOM; the length is checked first, since a BOM-only body is not empty.
|
|
57
|
+
*/
|
|
58
|
+
function decodeBody(bytes, contentType, bodyType) {
|
|
59
|
+
if (bytes.length === 0) return bodyType === TEXT ? '' : bodyType === BINARY ? new Uint8Array(0) : null;
|
|
60
|
+
const essence = mimeEssence(contentType);
|
|
61
|
+
const json = bodyType === JSON_BODY || (bodyType === AUTO && isJsonType(essence));
|
|
62
|
+
const text = json || bodyType === TEXT || (bodyType === AUTO && isTextType(essence));
|
|
63
|
+
if (!text) return bytes;
|
|
64
|
+
const decoded = textDecoder.decode(bytes);
|
|
65
|
+
if (!json) return decoded;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(decoded);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (bodyType === AUTO) return decoded; // mislabelled responses fall back to text
|
|
70
|
+
throw coded(error, 'RUST_FETCH_PARSE');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Undo one Content-Encoding; unknown encodings are passed through, like fetch() */
|
|
75
|
+
function decode(body, encoding) {
|
|
76
|
+
switch (encoding) {
|
|
77
|
+
case 'gzip':
|
|
78
|
+
case 'x-gzip':
|
|
79
|
+
return zlib.gunzipSync(body, { finishFlush: zlib.constants.Z_SYNC_FLUSH });
|
|
80
|
+
case 'deflate':
|
|
81
|
+
// Servers send both zlib-wrapped and raw deflate under this name
|
|
82
|
+
return body[0] === 0x78 ? zlib.inflateSync(body) : zlib.inflateRawSync(body);
|
|
83
|
+
case 'br':
|
|
84
|
+
return zlib.brotliDecompressSync(body);
|
|
85
|
+
case 'zstd':
|
|
86
|
+
return typeof zlib.zstdDecompressSync === 'function' ? zlib.zstdDecompressSync(body) : body;
|
|
87
|
+
default:
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function decompress(body, contentEncoding) {
|
|
93
|
+
if (!contentEncoding || body.length === 0) return body;
|
|
94
|
+
// Encodings are listed in the order they were applied
|
|
95
|
+
const encodings = contentEncoding.toLowerCase().split(',').map((e) => e.trim()).filter((e) => e && e !== 'identity');
|
|
96
|
+
for (let i = encodings.length - 1; i >= 0; i--) body = decode(body, encodings[i]);
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Response headers as one string per name; repeated headers joined with ", " */
|
|
101
|
+
function flattenHeaders(raw) {
|
|
102
|
+
const headers = {};
|
|
103
|
+
for (const name in raw) {
|
|
104
|
+
const value = raw[name];
|
|
105
|
+
headers[name] = Array.isArray(value) ? value.join(', ') : value;
|
|
106
|
+
}
|
|
107
|
+
return headers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A plain Uint8Array that owns its memory. Small Node Buffers share a pooled
|
|
112
|
+
* ArrayBuffer, so those are copied; larger ones are wrapped without copying.
|
|
113
|
+
*/
|
|
114
|
+
function ownedBytes(bytes) {
|
|
115
|
+
return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
116
|
+
? new Uint8Array(bytes.buffer)
|
|
117
|
+
: new Uint8Array(bytes);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function withoutHeaders(headers, names) {
|
|
121
|
+
const copy = { ...headers };
|
|
122
|
+
for (const name of names) delete copy[name];
|
|
123
|
+
return copy;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function send(state, method, url, headers, body, redirects, resolve, reject) {
|
|
127
|
+
if (state.cancelled) return;
|
|
128
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
129
|
+
const request = transport.request(url, { method, headers }, (response) => {
|
|
130
|
+
const status = response.statusCode;
|
|
131
|
+
const location = response.headers.location;
|
|
132
|
+
|
|
133
|
+
if (REDIRECT_STATUSES.has(status) && location) {
|
|
134
|
+
response.resume();
|
|
135
|
+
if (redirects >= MAX_REDIRECTS) return reject(new TypeError(`Too many redirects (more than ${MAX_REDIRECTS})`));
|
|
136
|
+
let next;
|
|
137
|
+
try {
|
|
138
|
+
next = new URL(location, url);
|
|
139
|
+
} catch {
|
|
140
|
+
return reject(new TypeError(`Invalid redirect location: ${location}`));
|
|
141
|
+
}
|
|
142
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
|
143
|
+
return reject(new TypeError(`Unsupported redirect to ${next.protocol}`));
|
|
144
|
+
}
|
|
145
|
+
// Same rules as fetch(): 303, and 301/302 after POST, become a bodiless GET
|
|
146
|
+
let nextMethod = method;
|
|
147
|
+
let nextBody = body;
|
|
148
|
+
let nextHeaders = headers;
|
|
149
|
+
if ((status === 303 && method !== 'GET' && method !== 'HEAD') || ((status === 301 || status === 302) && method === 'POST')) {
|
|
150
|
+
nextMethod = 'GET';
|
|
151
|
+
nextBody = undefined;
|
|
152
|
+
nextHeaders = withoutHeaders(nextHeaders, ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location']);
|
|
153
|
+
}
|
|
154
|
+
// Never forward credentials to another origin
|
|
155
|
+
if (next.origin !== url.origin) {
|
|
156
|
+
nextHeaders = withoutHeaders(nextHeaders, ['authorization', 'cookie', 'proxy-authorization']);
|
|
157
|
+
}
|
|
158
|
+
return send(state, nextMethod, next, nextHeaders, nextBody, redirects + 1, resolve, reject);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// With a Content-Length, fill one preallocated buffer (a single copy);
|
|
162
|
+
// otherwise collect chunks and join them at the end.
|
|
163
|
+
const expected = Number(response.headers['content-length']);
|
|
164
|
+
const preallocated = !response.headers['content-encoding'] && Number.isSafeInteger(expected) && expected > 0 && expected <= 512 * 1024 * 1024;
|
|
165
|
+
const buffer = preallocated ? new Uint8Array(expected) : undefined;
|
|
166
|
+
let received = 0;
|
|
167
|
+
const chunks = [];
|
|
168
|
+
response.on('data', (chunk) => {
|
|
169
|
+
if (buffer && received + chunk.length <= expected) buffer.set(chunk, received);
|
|
170
|
+
else chunks.push(chunk);
|
|
171
|
+
received += chunk.length;
|
|
172
|
+
});
|
|
173
|
+
response.on('error', reject);
|
|
174
|
+
// A socket closed mid-body may emit 'close' without 'end' (a settled promise ignores this)
|
|
175
|
+
response.on('close', () => {
|
|
176
|
+
if (!response.complete) reject(new TypeError('The connection closed before the response body was complete'));
|
|
177
|
+
});
|
|
178
|
+
response.on('end', () => {
|
|
179
|
+
if (!response.complete) return reject(new TypeError('The connection closed before the response body was complete'));
|
|
180
|
+
try {
|
|
181
|
+
let body;
|
|
182
|
+
if (buffer && received === expected && chunks.length === 0) {
|
|
183
|
+
body = buffer;
|
|
184
|
+
} else {
|
|
185
|
+
const raw = buffer ? Buffer.concat([buffer.subarray(0, Math.min(received, expected)), ...chunks]) : Buffer.concat(chunks);
|
|
186
|
+
body = ownedBytes(method === 'HEAD' ? raw : decompress(raw, response.headers['content-encoding']));
|
|
187
|
+
}
|
|
188
|
+
resolve({ status, url: url.href, headers: flattenHeaders(response.headers), body });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
reject(error);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
request.on('error', reject);
|
|
195
|
+
request.end(body);
|
|
196
|
+
state.request = request;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One attempt, called by the Rust core with
|
|
201
|
+
* `{ method, url, headers, body?, timeout?, bodyType }`. Resolves to the
|
|
202
|
+
* finished `{ status, statusText, ok, url, headers, body }` (the core fills
|
|
203
|
+
* in statusText). Rejects with code RUST_FETCH_TIMEOUT or RUST_FETCH_PARSE,
|
|
204
|
+
* or any other error for network failures.
|
|
205
|
+
*
|
|
206
|
+
* The promise has a `cancel()` method, which the core calls only when it
|
|
207
|
+
* abandons an attempt (e.g. AbortSignal); this is much cheaper than wiring an
|
|
208
|
+
* AbortSignal into every request.
|
|
209
|
+
*/
|
|
210
|
+
function nodeTransport(request) {
|
|
211
|
+
const state = { cancelled: false, request: undefined, bodyType: request.bodyType ?? AUTO };
|
|
212
|
+
let timer;
|
|
213
|
+
const promise = new Promise((resolve, reject) => {
|
|
214
|
+
if (request.timeout > 0) {
|
|
215
|
+
// Covers the whole attempt: connecting, redirects and reading the body
|
|
216
|
+
timer = setTimeout(() => {
|
|
217
|
+
cancel();
|
|
218
|
+
reject(coded(new Error(`Request timeout after ${request.timeout}ms`), 'RUST_FETCH_TIMEOUT'));
|
|
219
|
+
}, request.timeout);
|
|
220
|
+
}
|
|
221
|
+
let url;
|
|
222
|
+
try {
|
|
223
|
+
url = new URL(request.url);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
return reject(error);
|
|
227
|
+
}
|
|
228
|
+
const headers = { ...request.headers };
|
|
229
|
+
headers['accept'] ??= '*/*';
|
|
230
|
+
headers['accept-encoding'] ??= ACCEPT_ENCODING;
|
|
231
|
+
headers['user-agent'] ??= 'rust-fetch';
|
|
232
|
+
if (request.body) headers['content-length'] ??= String(request.body.length);
|
|
233
|
+
const settle = (fn) => (value) => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
fn(value);
|
|
236
|
+
};
|
|
237
|
+
send(state, request.method, url, headers, request.body, 0, settle(resolve), settle(reject));
|
|
238
|
+
});
|
|
239
|
+
const cancel = () => {
|
|
240
|
+
state.cancelled = true;
|
|
241
|
+
state.request?.destroy();
|
|
242
|
+
};
|
|
243
|
+
// Decode only after the socket's event handlers have returned: decoding
|
|
244
|
+
// inside them keeps the raw chunks and stream state alive during
|
|
245
|
+
// JSON.parse, which costs several times more garbage collection.
|
|
246
|
+
const finished = promise.then((response) => finishResponse(response, state.bodyType));
|
|
247
|
+
finished.cancel = () => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
cancel();
|
|
250
|
+
};
|
|
251
|
+
return finished;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The response object handed to the Rust core (which fills in statusText) */
|
|
255
|
+
function finishResponse({ status, url, headers, body }, bodyType) {
|
|
256
|
+
const ok = status >= 200 && status < 300;
|
|
257
|
+
// Error statuses carry their body as text (for HttpError.body)
|
|
258
|
+
const decoded = ok ? decodeBody(body, headers['content-type'], bodyType) : decodeBody(body, '', TEXT);
|
|
259
|
+
return { status, statusText: '', ok, url, headers, body: decoded };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
globalThis[Symbol.for('rust-fetch.transport')] = nodeTransport;
|
|
263
|
+
|
|
264
|
+
export { default } from './rust_fetch.js';
|
|
265
|
+
export * from './rust_fetch.js';
|
package/package.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"collaborators": [
|
|
5
5
|
"Asem Qaffaf <asem@qaffaf.com>"
|
|
6
6
|
],
|
|
7
|
-
"description": "A
|
|
8
|
-
"version": "
|
|
7
|
+
"description": "A reliable HTTP client for JavaScript and TypeScript, written in Rust and compiled to WebAssembly: timeouts, retries, typed errors and interceptors, faster than fetch() in Node.js",
|
|
8
|
+
"version": "2.1.0",
|
|
9
9
|
"license": "MIT OR Apache-2.0",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"rust_fetch_bg.wasm",
|
|
16
|
+
"rust_fetch_bg.wasm.d.ts",
|
|
16
17
|
"rust_fetch.js",
|
|
17
|
-
"rust_fetch.d.ts"
|
|
18
|
+
"rust_fetch.d.ts",
|
|
19
|
+
"node.js",
|
|
20
|
+
"LICENSE-MIT",
|
|
21
|
+
"LICENSE-APACHE"
|
|
18
22
|
],
|
|
19
23
|
"main": "rust_fetch.js",
|
|
20
24
|
"types": "rust_fetch.d.ts",
|
|
@@ -27,5 +31,25 @@
|
|
|
27
31
|
"client",
|
|
28
32
|
"fetch",
|
|
29
33
|
"webassembly"
|
|
30
|
-
]
|
|
31
|
-
|
|
34
|
+
],
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./rust_fetch.d.ts",
|
|
38
|
+
"node": "./node.js",
|
|
39
|
+
"default": "./rust_fetch.js"
|
|
40
|
+
},
|
|
41
|
+
"./rust_fetch.js": {
|
|
42
|
+
"types": "./rust_fetch.d.ts",
|
|
43
|
+
"default": "./rust_fetch.js"
|
|
44
|
+
},
|
|
45
|
+
"./rust_fetch_bg.wasm": "./rust_fetch_bg.wasm",
|
|
46
|
+
"./package.json": "./package.json"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/asemqaffaf/rust-fetch#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/asemqaffaf/rust-fetch/issues"
|
|
54
|
+
}
|
|
55
|
+
}
|