@ix-xs/node-comfort 2.0.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/CHANGELOG.md +150 -0
- package/README.md +32 -30
- package/index.js +3 -3
- package/package.json +8 -7
- package/src/Arr.js +222 -58
- package/src/Async.js +322 -90
- package/src/Cache.js +148 -44
- package/src/Checker.js +257 -26
- package/src/Cli.js +404 -145
- package/src/Color.js +224 -83
- package/src/Crypto.js +481 -106
- package/src/Emitter.js +104 -37
- package/src/Env.js +176 -42
- package/src/FS.js +203 -60
- package/src/Func.js +226 -76
- package/src/Http.js +311 -149
- package/src/Id.js +281 -96
- package/src/Logger.js +302 -78
- package/src/Num.js +182 -60
- package/src/Obj.js +340 -125
- package/src/SQLite.js +426 -105
- package/src/Schema.js +602 -87
- package/src/Str.js +316 -86
- package/src/Sys.js +197 -54
- package/src/Time.js +572 -317
- package/src/Utils.js +62 -41
- package/src/errors.js +54 -11
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +59 -17
- package/types/src/Arr.d.ts +78 -0
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +52 -1
- package/types/src/Async.d.ts.map +1 -1
- package/types/src/Cache.d.ts +33 -7
- package/types/src/Cache.d.ts.map +1 -1
- package/types/src/Checker.d.ts +155 -2
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +97 -15
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +7 -1
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +99 -13
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +36 -17
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +31 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +36 -5
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +147 -19
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +113 -43
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts +60 -8
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +53 -2
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +48 -7
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +67 -4
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +123 -14
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +256 -7
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +49 -1
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +51 -6
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +130 -34
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +15 -11
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +27 -3
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +123 -0
- package/types/src/internal/shared.d.ts.map +1 -0
package/src/Http.js
CHANGED
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
* const { data: users } = await api.get("users", { query: { page: 2 } });
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
const nodePath = require("node:path");
|
|
17
|
-
const { Readable, Transform } = require("node:stream");
|
|
18
|
-
const { pipeline } = require("node:stream/promises");
|
|
15
|
+
const { types } = require("node:util");
|
|
19
16
|
const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
17
|
+
const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
20
|
* A request body.
|
|
@@ -29,19 +27,25 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
|
29
27
|
*/
|
|
30
28
|
|
|
31
29
|
/**
|
|
32
|
-
* Query string values. `null` and `undefined` are skipped, arrays repeat the key.
|
|
33
|
-
* @typedef {Record<string, string | number | boolean | null | undefined | Array<string | number | boolean>>} QueryParams
|
|
30
|
+
* Query string values. `null` and `undefined` are skipped, arrays repeat the key, dates are sent in ISO format.
|
|
31
|
+
* @typedef {Record<string, string | number | boolean | Date | null | undefined | Array<string | number | boolean | Date>>} QueryParams
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Request headers: an object, a `Headers` instance or a list of pairs.
|
|
36
|
+
* @typedef {Record<string, string | undefined> | Headers | Array<[string, string]>} HttpHeaders
|
|
34
37
|
*/
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* Retry settings.
|
|
38
41
|
* @typedef {object} HttpRetryOptions
|
|
39
|
-
* @property {number} [attempts] Total attempts, the first included. Defaults to `3` for methods that are safe to repeat, `1` for the others.
|
|
40
|
-
* @property {number} [delay] Wait before the first retry, in ms
|
|
42
|
+
* @property {number} [attempts] Total attempts, the first included. Defaults to `3` for methods that are safe to repeat, `1` for the others. A number you give applies to every method, POST included.
|
|
43
|
+
* @property {number | string} [delay] Wait before the first retry, in ms or like `"1s"`. Defaults to `300`.
|
|
41
44
|
* @property {number} [backoff] Multiplies the wait after each attempt. Defaults to `2`.
|
|
42
|
-
* @property {number} [maxDelay] Longest wait, `Retry-After` included. Defaults to 30 seconds.
|
|
45
|
+
* @property {number | string} [maxDelay] Longest wait, `Retry-After` included. Defaults to 30 seconds.
|
|
46
|
+
* @property {boolean} [jitter] Shorten each wait by a random amount, up to half of it, so many clients don't all come back at the same instant.
|
|
43
47
|
* @property {number[]} [statuses] Statuses worth retrying. Defaults to 408, 413, 429, 500, 502, 503 and 504.
|
|
44
|
-
* @property {HttpMethod[]} [methods] Methods
|
|
48
|
+
* @property {HttpMethod[]} [methods] Methods retried when you give no number of attempts. POST and PATCH aren't, by default, so nothing gets created twice.
|
|
45
49
|
*/
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -67,15 +71,17 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
|
67
71
|
* @typedef {object} HttpOptions
|
|
68
72
|
* @property {HttpMethod} [method] Defaults to `"GET"`.
|
|
69
73
|
* @property {string} [baseURL] Prefix for relative URLs.
|
|
70
|
-
* @property {
|
|
71
|
-
* @property {
|
|
74
|
+
* @property {HttpHeaders} [headers] Set a header to `undefined` to remove a default one.
|
|
75
|
+
* @property {Record<string, string | number | boolean>} [params] Values for the `:name` parts of the URL: `"users/:id"` with `{ id: 42 }` gives `users/42`. They're encoded for you.
|
|
76
|
+
* @property {QueryParams | URLSearchParams} [query] Added to the URL's own query string, and to the client's default query.
|
|
72
77
|
* @property {unknown} [json] Sent as JSON.
|
|
73
78
|
* @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
|
|
74
79
|
* @property {HttpBody} [body] Sent as is: string, Buffer, `FormData`, stream...
|
|
75
80
|
* @property {{ bearer: string } | { username: string, password: string }} [auth] Sets the `authorization` header.
|
|
76
|
-
* @property {number | string | false} [timeout] Time limit per attempt, like `"10s"`. `false` turns it off. Defaults to 30 seconds.
|
|
77
|
-
* @property {number | false | HttpRetryOptions} [retry] Number of attempts, `false`, or retry settings.
|
|
81
|
+
* @property {number | string | false} [timeout] Time limit per attempt, like `"10s"`. `false` turns it off. Defaults to 30 seconds. With `responseType: "stream"`, it only covers the wait for the response headers.
|
|
82
|
+
* @property {number | false | HttpRetryOptions} [retry] Number of attempts, `false`, or retry settings. A request whose body is a stream is never retried.
|
|
78
83
|
* @property {"auto" | "json" | "text" | "buffer" | "stream"} [responseType] How to read the body. `"auto"` reads JSON or text based on the content type, a Buffer otherwise.
|
|
84
|
+
* @property {{ parse(data: unknown): unknown }} [schema] Checks the body of a 2xx response, and replaces it with the result: pass a `nc.schema` and bad data from an API stops at the door, with a `ValidationError`. `data` takes the type the schema describes.
|
|
79
85
|
* @property {boolean} [throwHttpErrors] Throw an `HttpError` when the status isn't 2xx. Defaults to `true`.
|
|
80
86
|
* @property {AbortSignal} [signal] Cancels the request.
|
|
81
87
|
* @property {HttpHooks} [hooks]
|
|
@@ -91,22 +97,35 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
|
91
97
|
* @property {string} statusText
|
|
92
98
|
* @property {boolean} ok `true` for 2xx statuses.
|
|
93
99
|
* @property {Record<string, string>} headers Lower-cased names.
|
|
100
|
+
* @property {string[]} cookies The `Set-Cookie` headers, one per cookie. `headers` can only hold one of them.
|
|
94
101
|
* @property {T} data The body, already read (see `responseType`).
|
|
95
102
|
* @property {string} url The final URL, after redirects.
|
|
96
103
|
* @property {number} duration In ms, retries included.
|
|
97
104
|
* @property {number} attempts How many attempts it took.
|
|
98
105
|
*/
|
|
99
106
|
|
|
107
|
+
/**
|
|
108
|
+
* What the `schema` option takes: a `nc.schema`, or anything else with a
|
|
109
|
+
* `parse()` method that returns the checked value or throws.
|
|
110
|
+
* @typedef {{ parse(data: unknown): unknown }} ResponseSchema
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Request options, where `schema` gives `response.data` its type.
|
|
115
|
+
* @template {ResponseSchema} S
|
|
116
|
+
* @typedef {Omit<HttpOptions, "schema"> & { schema?: S }} HttpOptionsOf
|
|
117
|
+
*/
|
|
118
|
+
|
|
100
119
|
/**
|
|
101
120
|
* A client with its own defaults, from `create()`.
|
|
102
121
|
* @typedef {object} HttpClient
|
|
103
|
-
* @property {<T = any>(url: string | URL, options?:
|
|
104
|
-
* @property {<T = any>(url: string | URL, options?:
|
|
122
|
+
* @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} request Sends a request.
|
|
123
|
+
* @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} get Sends a GET request.
|
|
105
124
|
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} head Sends a HEAD request.
|
|
106
|
-
* @property {<T = any>(url: string | URL, options?:
|
|
107
|
-
* @property {<T = any>(url: string | URL, data?: unknown, options?:
|
|
108
|
-
* @property {<T = any>(url: string | URL, data?: unknown, options?:
|
|
109
|
-
* @property {<T = any>(url: string | URL, data?: unknown, options?:
|
|
125
|
+
* @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} delete Sends a DELETE request.
|
|
126
|
+
* @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, data?: unknown, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} post Sends a POST request. Objects and arrays go as JSON.
|
|
127
|
+
* @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, data?: unknown, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} put Sends a PUT request. Objects and arrays go as JSON.
|
|
128
|
+
* @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, data?: unknown, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} patch Sends a PATCH request. Objects and arrays go as JSON.
|
|
110
129
|
* @property {(url: string | URL, destination: string, options?: HttpOptions) => Promise<{ path: string, size: number }>} download Saves a response to a file.
|
|
111
130
|
* @property {(defaults: HttpOptions) => HttpClient} extend A new client with these defaults added.
|
|
112
131
|
* @property {Readonly<HttpOptions>} defaults
|
|
@@ -114,32 +133,100 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
|
114
133
|
|
|
115
134
|
const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
|
|
116
135
|
const RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
136
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
137
|
+
const ABSOLUTE = /^[a-z][a-z\d+.-]*:/i;
|
|
138
|
+
// ":name" right after a slash, which leaves "http://host:3000" alone
|
|
139
|
+
const PARAM = /(?<=\/):([A-Za-z_]\w*)/g;
|
|
117
140
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
141
|
+
/**
|
|
142
|
+
* @param {number | string | false | undefined} value
|
|
143
|
+
* @returns {number | undefined} `undefined` for no limit.
|
|
144
|
+
*/
|
|
145
|
+
const _timeout = (value) => {
|
|
120
146
|
if (value === false) return undefined;
|
|
121
|
-
if (value === undefined) return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
if (value === undefined) return DEFAULT_TIMEOUT;
|
|
148
|
+
const ms = toMs(value, "timeout");
|
|
149
|
+
return ms === Infinity ? undefined : ms;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Headers as an object with lower-cased names.
|
|
154
|
+
* @param {HttpHeaders | undefined} input
|
|
155
|
+
* @returns {Record<string, string | undefined>}
|
|
156
|
+
*/
|
|
157
|
+
const _headerRecord = (input) => {
|
|
158
|
+
/** @type {Record<string, string | undefined>} */
|
|
159
|
+
const out = {};
|
|
160
|
+
if (!input) return out;
|
|
161
|
+
if (typeof (/** @type {Headers} */ (input).forEach) === "function" && !Array.isArray(input)) {
|
|
162
|
+
/** @type {Headers} */ (input).forEach((value, key) => {
|
|
163
|
+
out[key.toLowerCase()] = value;
|
|
164
|
+
});
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
for (const [key, value] of Array.isArray(input) ? input : Object.entries(input)) {
|
|
168
|
+
out[String(key).toLowerCase()] = value === undefined || value === null ? undefined : String(value);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
126
171
|
};
|
|
127
172
|
|
|
173
|
+
/** @param {unknown} value */
|
|
174
|
+
const _queryValue = (value) => (types.isDate(value) ? value.toISOString() : String(value));
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {URLSearchParams} params
|
|
178
|
+
* @param {QueryParams | URLSearchParams | undefined} query
|
|
179
|
+
*/
|
|
180
|
+
const _appendQuery = (params, query) => {
|
|
181
|
+
if (!query) return;
|
|
182
|
+
if (typeof (/** @type {URLSearchParams} */ (query).append) === "function") {
|
|
183
|
+
for (const [key, value] of /** @type {URLSearchParams} */ (query)) params.append(key, value);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const [key, value] of Object.entries(query)) {
|
|
187
|
+
if (value === undefined || value === null) continue;
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
params.delete(key);
|
|
190
|
+
for (const item of value) params.append(key, _queryValue(item));
|
|
191
|
+
} else {
|
|
192
|
+
params.set(key, _queryValue(value));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @template T
|
|
199
|
+
* @param {T[] | undefined} a
|
|
200
|
+
* @param {T[] | undefined} b
|
|
201
|
+
* @returns {T[] | undefined}
|
|
202
|
+
*/
|
|
203
|
+
const _concat = (a, b) => (a?.length && b?.length ? [...a, ...b] : b?.length ? b : a);
|
|
204
|
+
|
|
128
205
|
/**
|
|
129
206
|
* @param {HttpOptions} base
|
|
130
207
|
* @param {HttpOptions} extra
|
|
131
208
|
* @returns {HttpOptions}
|
|
132
209
|
*/
|
|
133
|
-
const _mergeOptions = (base, extra) =>
|
|
134
|
-
...base,
|
|
135
|
-
...extra
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
210
|
+
const _mergeOptions = (base, extra) => {
|
|
211
|
+
const merged = { ...base, ...extra };
|
|
212
|
+
if (base.headers && extra.headers) merged.headers = { ..._headerRecord(base.headers), ..._headerRecord(extra.headers) };
|
|
213
|
+
if (base.params && extra.params) merged.params = { ...base.params, ...extra.params };
|
|
214
|
+
if (base.query && extra.query) {
|
|
215
|
+
// The client's query stays, the call adds to it or replaces a key.
|
|
216
|
+
const query = new URLSearchParams();
|
|
217
|
+
_appendQuery(query, base.query);
|
|
218
|
+
_appendQuery(query, extra.query);
|
|
219
|
+
merged.query = query;
|
|
220
|
+
}
|
|
221
|
+
if (base.hooks && extra.hooks) {
|
|
222
|
+
merged.hooks = {
|
|
223
|
+
beforeRequest: _concat(base.hooks.beforeRequest, extra.hooks.beforeRequest),
|
|
224
|
+
afterResponse: _concat(base.hooks.afterResponse, extra.hooks.afterResponse),
|
|
225
|
+
beforeRetry: _concat(base.hooks.beforeRetry, extra.hooks.beforeRetry),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return merged;
|
|
229
|
+
};
|
|
143
230
|
|
|
144
231
|
/**
|
|
145
232
|
* @param {string | URL} input
|
|
@@ -148,40 +235,44 @@ const _mergeOptions = (base, extra) => ({
|
|
|
148
235
|
*/
|
|
149
236
|
const _buildURL = (input, options) => {
|
|
150
237
|
let url;
|
|
151
|
-
if (input
|
|
152
|
-
|
|
153
|
-
else
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
238
|
+
if (typeof input !== "string") {
|
|
239
|
+
url = new URL(String(input));
|
|
240
|
+
} else {
|
|
241
|
+
const params = options.params;
|
|
242
|
+
const path = !params ? input : input.replace(PARAM, (_, name) => {
|
|
243
|
+
const value = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
244
|
+
if (value === undefined || value === null) throw new TypeError(`Missing URL parameter "${name}" for ${input}`);
|
|
245
|
+
return encodeURIComponent(String(value));
|
|
246
|
+
});
|
|
247
|
+
if (ABSOLUTE.test(path)) url = new URL(path);
|
|
248
|
+
else if (options.baseURL) url = new URL(path.replace(/^\/+/, ""), String(options.baseURL).replace(/\/*$/, "/"));
|
|
249
|
+
else throw new TypeError(`Invalid URL "${input}": use an absolute URL or set baseURL`);
|
|
163
250
|
}
|
|
251
|
+
_appendQuery(url.searchParams, options.query);
|
|
164
252
|
return url.href;
|
|
165
253
|
};
|
|
166
254
|
|
|
255
|
+
/** @param {unknown} body */
|
|
256
|
+
const _isStream = (body) =>
|
|
257
|
+
body !== null && typeof body === "object" && (typeof (/** @type {any} */ (body).pipe) === "function" || typeof (/** @type {any} */ (body).getReader) === "function");
|
|
258
|
+
|
|
167
259
|
/**
|
|
168
260
|
* @param {Response} res
|
|
169
|
-
* @param {HttpOptions}
|
|
261
|
+
* @param {HttpOptions["responseType"]} type
|
|
262
|
+
* @param {HttpOptions["onDownloadProgress"]} onProgress
|
|
170
263
|
* @returns {Promise<unknown>}
|
|
171
264
|
*/
|
|
172
|
-
const _readBody = async (res,
|
|
173
|
-
const type = options.responseType ?? "auto";
|
|
174
|
-
if (type === "stream") return res.body ? Readable.fromWeb(/** @type {any} */ (res.body)) : Readable.from([]);
|
|
265
|
+
const _readBody = async (res, type = "auto", onProgress) => {
|
|
175
266
|
let buffer;
|
|
176
|
-
if (
|
|
267
|
+
if (onProgress && res.body) {
|
|
177
268
|
const total = Number(res.headers.get("content-length")) || undefined;
|
|
178
269
|
/** @type {Buffer[]} */
|
|
179
270
|
const chunks = [];
|
|
180
271
|
let loaded = 0;
|
|
181
272
|
for await (const chunk of /** @type {AsyncIterable<Uint8Array>} */ (/** @type {unknown} */ (res.body))) {
|
|
182
|
-
chunks.push(Buffer.from(chunk));
|
|
273
|
+
chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
183
274
|
loaded += chunk.length;
|
|
184
|
-
|
|
275
|
+
onProgress({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
|
|
185
276
|
}
|
|
186
277
|
buffer = Buffer.concat(chunks);
|
|
187
278
|
} else {
|
|
@@ -199,16 +290,10 @@ const _readBody = async (res, options) => {
|
|
|
199
290
|
return text;
|
|
200
291
|
}
|
|
201
292
|
}
|
|
202
|
-
if (type === "text" || type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType)) return text;
|
|
293
|
+
if (type === "text" || (type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType))) return text;
|
|
203
294
|
return buffer;
|
|
204
295
|
};
|
|
205
296
|
|
|
206
|
-
/**
|
|
207
|
-
* @param {Headers} headers
|
|
208
|
-
* @returns {Record<string, string>}
|
|
209
|
-
*/
|
|
210
|
-
const _headers = (headers) => Object.fromEntries(headers.entries());
|
|
211
|
-
|
|
212
297
|
/**
|
|
213
298
|
* The delay asked for by a `Retry-After` header, in ms.
|
|
214
299
|
* @param {string | null} value
|
|
@@ -231,21 +316,19 @@ const _retryAfter = (value) => {
|
|
|
231
316
|
const _send = async (input, options) => {
|
|
232
317
|
const started = Date.now();
|
|
233
318
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
? Math.max(1, options.retry)
|
|
240
|
-
: retryOptions.attempts ?? (retryMethods.includes(method) ? 3 : 1);
|
|
241
|
-
const statuses = retryOptions.statuses ?? RETRY_STATUSES;
|
|
242
|
-
const timeoutMs = _ms(options.timeout);
|
|
319
|
+
const signal = options.signal;
|
|
320
|
+
const hooks = options.hooks;
|
|
321
|
+
const retry = typeof options.retry === "object" && options.retry !== null ? options.retry : {};
|
|
322
|
+
const statuses = retry.statuses ?? RETRY_STATUSES;
|
|
323
|
+
const timeoutMs = _timeout(options.timeout);
|
|
243
324
|
|
|
244
325
|
/** @type {Record<string, string>} */
|
|
245
326
|
const headers = { accept: "application/json, text/plain, */*" };
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
327
|
+
const given = _headerRecord(options.headers);
|
|
328
|
+
for (const key of Object.keys(given)) {
|
|
329
|
+
const value = given[key];
|
|
330
|
+
if (value === undefined) delete headers[key];
|
|
331
|
+
else headers[key] = value;
|
|
249
332
|
}
|
|
250
333
|
if (options.auth) {
|
|
251
334
|
headers.authorization = "bearer" in options.auth
|
|
@@ -261,62 +344,120 @@ const _send = async (input, options) => {
|
|
|
261
344
|
body = new URLSearchParams(Object.entries(options.form).map(([k, v]) => /** @type {[string, string]} */ ([k, String(v)])));
|
|
262
345
|
}
|
|
263
346
|
|
|
264
|
-
|
|
265
|
-
|
|
347
|
+
// A stream can only be read once, so there's nothing to send a second time.
|
|
348
|
+
const attempts = _isStream(body) || options.retry === false
|
|
349
|
+
? 1
|
|
350
|
+
: typeof options.retry === "number"
|
|
351
|
+
? Math.max(1, Math.floor(options.retry))
|
|
352
|
+
: retry.attempts ?? ((retry.methods ?? RETRY_METHODS).some((allowed) => allowed.toUpperCase() === method) ? 3 : 1);
|
|
353
|
+
|
|
354
|
+
for (let attempt = 1; ; attempt++) {
|
|
266
355
|
/** @type {HttpRequestConfig} */
|
|
267
356
|
const config = { url: _buildURL(input, options), method, headers: { ...headers }, body, attempt };
|
|
268
|
-
for (const hook of
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
357
|
+
if (hooks?.beforeRequest) for (const hook of hooks.beforeRequest) await hook(config);
|
|
358
|
+
if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal.reason });
|
|
359
|
+
|
|
360
|
+
const controller = new AbortController();
|
|
361
|
+
let timedOut = false;
|
|
362
|
+
const stopTimer = timeoutMs === undefined ? undefined : setLongTimeout(() => {
|
|
363
|
+
timedOut = true;
|
|
364
|
+
controller.abort();
|
|
365
|
+
}, timeoutMs, true);
|
|
366
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
367
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
368
|
+
const release = () => {
|
|
369
|
+
stopTimer?.();
|
|
370
|
+
signal?.removeEventListener("abort", onAbort);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/** @type {unknown} */
|
|
374
|
+
let failure;
|
|
375
|
+
let retryable = true;
|
|
272
376
|
/** @type {number | undefined} */
|
|
273
377
|
let serverDelay;
|
|
378
|
+
/** @type {Response | undefined} */
|
|
379
|
+
let res;
|
|
380
|
+
/** @type {unknown} */
|
|
381
|
+
let data;
|
|
382
|
+
let streaming = false;
|
|
274
383
|
try {
|
|
384
|
+
const sent = method === "GET" || method === "HEAD" ? undefined : config.body;
|
|
275
385
|
/** @type {any} */
|
|
276
|
-
const init = {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
386
|
+
const init = { method, headers: config.headers, body: sent, signal: controller.signal, redirect: options.redirect ?? "follow" };
|
|
387
|
+
if (_isStream(sent)) init.duplex = "half";
|
|
388
|
+
res = await fetch(config.url, init);
|
|
389
|
+
// An error status is read whole, so the error can show what the server said.
|
|
390
|
+
streaming = options.responseType === "stream" && (res.ok || options.throwHttpErrors === false);
|
|
391
|
+
if (method === "HEAD") {
|
|
392
|
+
data = undefined;
|
|
393
|
+
} else if (streaming) {
|
|
394
|
+
// The headers are in: the time limit was for getting this far, not for reading a body of unknown size.
|
|
395
|
+
stopTimer?.();
|
|
396
|
+
const { Readable } = require("node:stream");
|
|
397
|
+
const stream = res.body ? Readable.fromWeb(/** @type {any} */ (res.body)) : Readable.from([]);
|
|
398
|
+
stream.once("close", release);
|
|
399
|
+
data = stream;
|
|
400
|
+
} else {
|
|
401
|
+
data = await _readBody(res, options.responseType === "stream" ? "auto" : options.responseType, options.onDownloadProgress);
|
|
290
402
|
}
|
|
291
|
-
if (result.ok || options.throwHttpErrors === false) return result;
|
|
292
|
-
lastError = new HttpError(`Request failed with status ${result.status}${result.statusText ? ` ${result.statusText}` : ""}: ${method} ${config.url}`, {
|
|
293
|
-
status: result.status, statusText: result.statusText, url: result.url, method, headers: result.headers, data: result.data,
|
|
294
|
-
});
|
|
295
|
-
serverDelay = _retryAfter(res.headers.get("retry-after"));
|
|
296
|
-
if (!statuses.includes(result.status)) throw lastError;
|
|
297
403
|
} catch (error) {
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
} else if (error
|
|
404
|
+
release();
|
|
405
|
+
if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal.reason });
|
|
406
|
+
if (timedOut) {
|
|
407
|
+
failure = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${config.url}`, { timeout: timeoutMs, cause: error });
|
|
408
|
+
} else if (error instanceof HttpError) {
|
|
409
|
+
throw error;
|
|
410
|
+
} else {
|
|
303
411
|
/** @type {any} */
|
|
304
|
-
const
|
|
412
|
+
const cause = error;
|
|
305
413
|
/** @type {string} */
|
|
306
|
-
const reason =
|
|
307
|
-
|
|
308
|
-
} else if (error !== lastError) {
|
|
309
|
-
lastError = error;
|
|
414
|
+
const reason = cause?.cause?.code ?? cause?.cause?.message ?? cause?.message ?? String(error);
|
|
415
|
+
failure = new HttpError(`Network error (${reason}): ${method} ${config.url}`, { status: 0, url: config.url, method, code: "ERR_NETWORK", cause: error });
|
|
310
416
|
}
|
|
311
417
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
418
|
+
|
|
419
|
+
if (res && failure === undefined) {
|
|
420
|
+
if (!streaming) release();
|
|
421
|
+
/** @type {HttpResponse<any>} */
|
|
422
|
+
let result = {
|
|
423
|
+
status: res.status,
|
|
424
|
+
statusText: res.statusText,
|
|
425
|
+
ok: res.ok,
|
|
426
|
+
headers: Object.fromEntries(res.headers),
|
|
427
|
+
cookies: res.headers.getSetCookie(),
|
|
428
|
+
data,
|
|
429
|
+
url: res.url || config.url,
|
|
430
|
+
duration: Date.now() - started,
|
|
431
|
+
attempts: attempt,
|
|
432
|
+
};
|
|
433
|
+
if (hooks?.afterResponse) {
|
|
434
|
+
for (const hook of hooks.afterResponse) {
|
|
435
|
+
const replaced = await hook(result, config);
|
|
436
|
+
if (replaced) result = replaced;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (result.ok || options.throwHttpErrors === false) {
|
|
440
|
+
if (options.schema && result.ok) result.data = options.schema.parse(result.data);
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
failure = new HttpError(`Request failed with status ${result.status}${result.statusText ? ` ${result.statusText}` : ""}: ${method} ${config.url}`, {
|
|
444
|
+
status: result.status, statusText: result.statusText, url: result.url, method, headers: result.headers, data: result.data,
|
|
445
|
+
});
|
|
446
|
+
retryable = statuses.includes(result.status);
|
|
447
|
+
serverDelay = _retryAfter(res.headers.get("retry-after"));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!retryable || attempt >= attempts) throw failure;
|
|
451
|
+
const longest = toMs(retry.maxDelay ?? 30_000, "retry.maxDelay");
|
|
452
|
+
let wait = Math.min(serverDelay ?? toMs(retry.delay ?? 300, "retry.delay") * (retry.backoff ?? 2) ** (attempt - 1), longest);
|
|
453
|
+
if (retry.jitter && serverDelay === undefined) wait *= 0.5 + Math.random() / 2;
|
|
454
|
+
if (hooks?.beforeRetry) for (const hook of hooks.beforeRetry) await hook(failure, attempt, wait);
|
|
455
|
+
try {
|
|
456
|
+
await sleep(wait, signal);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal?.reason ?? error });
|
|
317
459
|
}
|
|
318
460
|
}
|
|
319
|
-
throw lastError;
|
|
320
461
|
};
|
|
321
462
|
|
|
322
463
|
/**
|
|
@@ -335,17 +476,17 @@ const _withData = (data, options) => {
|
|
|
335
476
|
* @returns {HttpClient}
|
|
336
477
|
*/
|
|
337
478
|
const _client = (defaults) => {
|
|
338
|
-
const merged = (/** @type {HttpOptions} */ options) => _mergeOptions(defaults, options
|
|
479
|
+
const merged = (/** @type {HttpOptions | undefined} */ options) => (options ? _mergeOptions(defaults, options) : defaults);
|
|
339
480
|
/** @type {HttpClient} */
|
|
340
481
|
const client = {
|
|
341
|
-
request: (url, options) => _send(url, merged(options
|
|
342
|
-
get: (url, options) => _send(url, { ...merged(options
|
|
343
|
-
head: (url, options) => _send(url, { ...merged(options
|
|
344
|
-
delete: (url, options) => _send(url, { ...merged(options
|
|
345
|
-
post: (url, data, options) => _send(url, { ..._withData(data, merged(options
|
|
346
|
-
put: (url, data, options) => _send(url, { ..._withData(data, merged(options
|
|
347
|
-
patch: (url, data, options) => _send(url, { ..._withData(data, merged(options
|
|
348
|
-
download: (url, destination, options) => download(url, destination, merged(options
|
|
482
|
+
request: (url, options) => _send(url, merged(options)),
|
|
483
|
+
get: (url, options) => _send(url, { ...merged(options), method: "GET" }),
|
|
484
|
+
head: (url, options) => _send(url, { ...merged(options), method: "HEAD" }),
|
|
485
|
+
delete: (url, options) => _send(url, { ...merged(options), method: "DELETE" }),
|
|
486
|
+
post: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "POST" }),
|
|
487
|
+
put: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PUT" }),
|
|
488
|
+
patch: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PATCH" }),
|
|
489
|
+
download: (url, destination, options) => download(url, destination, merged(options)),
|
|
349
490
|
extend: (more) => _client(_mergeOptions(defaults, more)),
|
|
350
491
|
defaults: Object.freeze({ ...defaults }),
|
|
351
492
|
};
|
|
@@ -366,10 +507,11 @@ const _client = (defaults) => {
|
|
|
366
507
|
* });
|
|
367
508
|
* res.data; // parsed JSON
|
|
368
509
|
*
|
|
369
|
-
* @template [T=any]
|
|
510
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
511
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
370
512
|
* @param {string | URL} url An absolute URL, or a path when `baseURL` is set.
|
|
371
|
-
* @param {
|
|
372
|
-
* @returns {Promise<HttpResponse<
|
|
513
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
514
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
373
515
|
* @throws {HttpError | TimeoutError | AbortError}
|
|
374
516
|
*/
|
|
375
517
|
function request(url, options = {}) {
|
|
@@ -382,10 +524,11 @@ function request(url, options = {}) {
|
|
|
382
524
|
* @example
|
|
383
525
|
* const { data } = await http.get("https://api.example.com/users", { query: { page: 2, tags: ["a", "b"] } });
|
|
384
526
|
*
|
|
385
|
-
* @template [T=any]
|
|
527
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
528
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
386
529
|
* @param {string | URL} url
|
|
387
|
-
* @param {
|
|
388
|
-
* @returns {Promise<HttpResponse<
|
|
530
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
531
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
389
532
|
*/
|
|
390
533
|
function get(url, options = {}) {
|
|
391
534
|
return _send(url, { ...options, method: "GET" });
|
|
@@ -415,11 +558,12 @@ function head(url, options = {}) {
|
|
|
415
558
|
* const { data: user } = await http.post("https://api.example.com/users", { name: "Ada" });
|
|
416
559
|
* await http.post(uploadUrl, formData);
|
|
417
560
|
*
|
|
418
|
-
* @template [T=any]
|
|
561
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
562
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
419
563
|
* @param {string | URL} url
|
|
420
564
|
* @param {unknown} [data]
|
|
421
|
-
* @param {
|
|
422
|
-
* @returns {Promise<HttpResponse<
|
|
565
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
566
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
423
567
|
*/
|
|
424
568
|
function post(url, data, options = {}) {
|
|
425
569
|
return _send(url, { ..._withData(data, options), method: "POST" });
|
|
@@ -428,11 +572,12 @@ function post(url, data, options = {}) {
|
|
|
428
572
|
/**
|
|
429
573
|
* Sends a PUT request. The body works like in `post()`.
|
|
430
574
|
*
|
|
431
|
-
* @template [T=any]
|
|
575
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
576
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
432
577
|
* @param {string | URL} url
|
|
433
578
|
* @param {unknown} [data]
|
|
434
|
-
* @param {
|
|
435
|
-
* @returns {Promise<HttpResponse<
|
|
579
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
580
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
436
581
|
*/
|
|
437
582
|
function put(url, data, options = {}) {
|
|
438
583
|
return _send(url, { ..._withData(data, options), method: "PUT" });
|
|
@@ -441,11 +586,12 @@ function put(url, data, options = {}) {
|
|
|
441
586
|
/**
|
|
442
587
|
* Sends a PATCH request. The body works like in `post()`.
|
|
443
588
|
*
|
|
444
|
-
* @template [T=any]
|
|
589
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
590
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
445
591
|
* @param {string | URL} url
|
|
446
592
|
* @param {unknown} [data]
|
|
447
|
-
* @param {
|
|
448
|
-
* @returns {Promise<HttpResponse<
|
|
593
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
594
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
449
595
|
*/
|
|
450
596
|
function patch(url, data, options = {}) {
|
|
451
597
|
return _send(url, { ..._withData(data, options), method: "PATCH" });
|
|
@@ -457,10 +603,11 @@ function patch(url, data, options = {}) {
|
|
|
457
603
|
* @example
|
|
458
604
|
* await http.delete(`https://api.example.com/users/${id}`);
|
|
459
605
|
*
|
|
460
|
-
* @template [T=any]
|
|
606
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
607
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
461
608
|
* @param {string | URL} url
|
|
462
|
-
* @param {
|
|
463
|
-
* @returns {Promise<HttpResponse<
|
|
609
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
610
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
464
611
|
*/
|
|
465
612
|
function del(url, options = {}) {
|
|
466
613
|
return _send(url, { ...options, method: "DELETE" });
|
|
@@ -468,7 +615,12 @@ function del(url, options = {}) {
|
|
|
468
615
|
|
|
469
616
|
/**
|
|
470
617
|
* Downloads to a file as a stream, so memory stays flat whatever the size.
|
|
471
|
-
* Folders are created
|
|
618
|
+
* Folders are created. The data goes to a temporary file that takes the
|
|
619
|
+
* final name once complete, so a failed or interrupted download never
|
|
620
|
+
* leaves half a file where you expect a whole one.
|
|
621
|
+
*
|
|
622
|
+
* `timeout` is how long the server gets to start answering; the transfer
|
|
623
|
+
* itself can take as long as it needs. Use `signal` to put a limit on it.
|
|
472
624
|
*
|
|
473
625
|
* @example
|
|
474
626
|
* await http.download("https://example.com/video.mp4", "./downloads/video.mp4", {
|
|
@@ -482,10 +634,15 @@ function del(url, options = {}) {
|
|
|
482
634
|
* @throws {HttpError | TimeoutError | AbortError}
|
|
483
635
|
*/
|
|
484
636
|
async function download(url, destination, options = {}) {
|
|
637
|
+
const fs = require("node:fs");
|
|
638
|
+
const nodePath = require("node:path");
|
|
639
|
+
const { Transform } = require("node:stream");
|
|
640
|
+
const { pipeline } = require("node:stream/promises");
|
|
485
641
|
const target = nodePath.resolve(destination);
|
|
486
642
|
fs.mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
487
|
-
const res = await _send(url, { ...options, responseType: "stream", onDownloadProgress: undefined,
|
|
643
|
+
const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined });
|
|
488
644
|
const total = Number(res.headers["content-length"]) || undefined;
|
|
645
|
+
const partial = `${target}.${process.pid}-${Date.now().toString(36)}.part`;
|
|
489
646
|
let size = 0;
|
|
490
647
|
const counter = new Transform({
|
|
491
648
|
transform(chunk, _encoding, callback) {
|
|
@@ -495,11 +652,16 @@ async function download(url, destination, options = {}) {
|
|
|
495
652
|
},
|
|
496
653
|
});
|
|
497
654
|
try {
|
|
498
|
-
await pipeline(/** @type {Readable} */ (res.data), counter, fs.createWriteStream(
|
|
655
|
+
await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, fs.createWriteStream(partial), { signal: options.signal });
|
|
656
|
+
fs.renameSync(partial, target);
|
|
499
657
|
} catch (error) {
|
|
500
|
-
fs.rmSync(
|
|
658
|
+
fs.rmSync(partial, { force: true });
|
|
501
659
|
if (options.signal?.aborted) throw new AbortError(`Download aborted: ${String(url)}`, { cause: error });
|
|
502
|
-
|
|
660
|
+
/** @type {any} */
|
|
661
|
+
const cause = error;
|
|
662
|
+
// What went wrong on the disk names a file and stays as it is; the rest is the connection.
|
|
663
|
+
if (typeof cause?.path === "string") throw error;
|
|
664
|
+
throw new HttpError(`Download interrupted (${cause?.cause?.code ?? cause?.code ?? cause?.message ?? String(error)}): ${res.url}`, { status: 0, url: res.url, method: "GET", code: "ERR_NETWORK", cause: error });
|
|
503
665
|
}
|
|
504
666
|
return { path: target, size };
|
|
505
667
|
}
|