@ix-xs/node-comfort 1.2.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 +340 -0
- package/README.md +137 -548
- package/index.js +80 -35
- package/package.json +161 -33
- package/src/Arr.js +1050 -418
- package/src/Async.js +817 -0
- package/src/Cache.js +433 -0
- package/src/Checker.js +1202 -539
- package/src/Cli.js +1293 -0
- package/src/Color.js +556 -0
- package/src/Crypto.js +1082 -0
- package/src/Emitter.js +356 -0
- package/src/Env.js +689 -0
- package/src/FS.js +1626 -894
- package/src/Func.js +823 -356
- package/src/Http.js +700 -0
- package/src/Id.js +568 -200
- package/src/Logger.js +963 -378
- package/src/Num.js +965 -360
- package/src/Obj.js +1077 -379
- package/src/SQLite.js +1629 -1005
- package/src/Schema.js +2101 -0
- package/src/Str.js +1209 -432
- package/src/Sys.js +654 -0
- package/src/Time.js +1533 -440
- package/src/Utils.js +342 -325
- package/src/errors.js +335 -0
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +735 -526
- package/types/src/Arr.d.ts +533 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +372 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +221 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +651 -232
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +664 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +300 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +620 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +189 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +412 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +717 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +508 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +444 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +199 -66
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +414 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +514 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +453 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +754 -308
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +1189 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +617 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +314 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +759 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +158 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +316 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/internal/shared.d.ts +123 -0
- package/types/src/internal/shared.d.ts.map +1 -0
- package/types/index.d.ts.map +0 -1
package/src/Http.js
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An HTTP client on top of `fetch`: JSON by default, query objects, base
|
|
5
|
+
* URLs, timeouts, safe retries that respect `Retry-After`, typed errors,
|
|
6
|
+
* hooks, reusable clients and downloads with progress.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const { data } = await http.get("https://api.github.com/repos/nodejs/node");
|
|
10
|
+
*
|
|
11
|
+
* const api = http.create({ baseURL: "https://api.example.com/v1/", auth: { bearer: process.env.TOKEN } });
|
|
12
|
+
* const { data: users } = await api.get("users", { query: { page: 2 } });
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { types } = require("node:util");
|
|
16
|
+
const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
17
|
+
const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A request body.
|
|
21
|
+
* @typedef {string | Buffer | Uint8Array | ArrayBuffer | URLSearchParams | FormData | Blob | ReadableStream | import("node:stream").Readable} HttpBody
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An HTTP method.
|
|
26
|
+
* @typedef {"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | (string & {})} HttpMethod
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
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
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Retry settings.
|
|
41
|
+
* @typedef {object} HttpRetryOptions
|
|
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`.
|
|
44
|
+
* @property {number} [backoff] Multiplies the wait after each attempt. Defaults to `2`.
|
|
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.
|
|
47
|
+
* @property {number[]} [statuses] Statuses worth retrying. Defaults to 408, 413, 429, 500, 502, 503 and 504.
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A request as hooks see it.
|
|
53
|
+
* @typedef {object} HttpRequestConfig
|
|
54
|
+
* @property {string} url The final URL, base URL and query included.
|
|
55
|
+
* @property {HttpMethod} method
|
|
56
|
+
* @property {Record<string, string>} headers Lower-cased names.
|
|
57
|
+
* @property {HttpBody | undefined} body
|
|
58
|
+
* @property {number} attempt 1 for the first try.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hooks to watch or change requests.
|
|
63
|
+
* @typedef {object} HttpHooks
|
|
64
|
+
* @property {Array<(config: HttpRequestConfig) => void | Promise<void>>} [beforeRequest] Runs before each attempt. It can change the URL, headers or body.
|
|
65
|
+
* @property {Array<(response: HttpResponse<any>, config: HttpRequestConfig) => void | HttpResponse<any> | Promise<void | HttpResponse<any>>>} [afterResponse] Runs after each response, before errors are thrown. It can return another response.
|
|
66
|
+
* @property {Array<(error: unknown, attempt: number, delay: number) => void | Promise<void>>} [beforeRetry] Runs before waiting for a retry.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Request options.
|
|
71
|
+
* @typedef {object} HttpOptions
|
|
72
|
+
* @property {HttpMethod} [method] Defaults to `"GET"`.
|
|
73
|
+
* @property {string} [baseURL] Prefix for relative URLs.
|
|
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.
|
|
77
|
+
* @property {unknown} [json] Sent as JSON.
|
|
78
|
+
* @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
|
|
79
|
+
* @property {HttpBody} [body] Sent as is: string, Buffer, `FormData`, stream...
|
|
80
|
+
* @property {{ bearer: string } | { username: string, password: string }} [auth] Sets the `authorization` header.
|
|
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.
|
|
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.
|
|
85
|
+
* @property {boolean} [throwHttpErrors] Throw an `HttpError` when the status isn't 2xx. Defaults to `true`.
|
|
86
|
+
* @property {AbortSignal} [signal] Cancels the request.
|
|
87
|
+
* @property {HttpHooks} [hooks]
|
|
88
|
+
* @property {(progress: { loaded: number, total: number | undefined, percent: number | undefined }) => void} [onDownloadProgress] Called while the body downloads.
|
|
89
|
+
* @property {"follow" | "error" | "manual"} [redirect] Defaults to `"follow"`.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A response.
|
|
94
|
+
* @template [T=any]
|
|
95
|
+
* @typedef {object} HttpResponse
|
|
96
|
+
* @property {number} status
|
|
97
|
+
* @property {string} statusText
|
|
98
|
+
* @property {boolean} ok `true` for 2xx statuses.
|
|
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.
|
|
101
|
+
* @property {T} data The body, already read (see `responseType`).
|
|
102
|
+
* @property {string} url The final URL, after redirects.
|
|
103
|
+
* @property {number} duration In ms, retries included.
|
|
104
|
+
* @property {number} attempts How many attempts it took.
|
|
105
|
+
*/
|
|
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
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A client with its own defaults, from `create()`.
|
|
121
|
+
* @typedef {object} HttpClient
|
|
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.
|
|
124
|
+
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} head Sends a HEAD request.
|
|
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.
|
|
129
|
+
* @property {(url: string | URL, destination: string, options?: HttpOptions) => Promise<{ path: string, size: number }>} download Saves a response to a file.
|
|
130
|
+
* @property {(defaults: HttpOptions) => HttpClient} extend A new client with these defaults added.
|
|
131
|
+
* @property {Readonly<HttpOptions>} defaults
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
|
|
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;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {number | string | false | undefined} value
|
|
143
|
+
* @returns {number | undefined} `undefined` for no limit.
|
|
144
|
+
*/
|
|
145
|
+
const _timeout = (value) => {
|
|
146
|
+
if (value === false) return undefined;
|
|
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;
|
|
171
|
+
};
|
|
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
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param {HttpOptions} base
|
|
207
|
+
* @param {HttpOptions} extra
|
|
208
|
+
* @returns {HttpOptions}
|
|
209
|
+
*/
|
|
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
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {string | URL} input
|
|
233
|
+
* @param {HttpOptions} options
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
const _buildURL = (input, options) => {
|
|
237
|
+
let url;
|
|
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`);
|
|
250
|
+
}
|
|
251
|
+
_appendQuery(url.searchParams, options.query);
|
|
252
|
+
return url.href;
|
|
253
|
+
};
|
|
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
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {Response} res
|
|
261
|
+
* @param {HttpOptions["responseType"]} type
|
|
262
|
+
* @param {HttpOptions["onDownloadProgress"]} onProgress
|
|
263
|
+
* @returns {Promise<unknown>}
|
|
264
|
+
*/
|
|
265
|
+
const _readBody = async (res, type = "auto", onProgress) => {
|
|
266
|
+
let buffer;
|
|
267
|
+
if (onProgress && res.body) {
|
|
268
|
+
const total = Number(res.headers.get("content-length")) || undefined;
|
|
269
|
+
/** @type {Buffer[]} */
|
|
270
|
+
const chunks = [];
|
|
271
|
+
let loaded = 0;
|
|
272
|
+
for await (const chunk of /** @type {AsyncIterable<Uint8Array>} */ (/** @type {unknown} */ (res.body))) {
|
|
273
|
+
chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
274
|
+
loaded += chunk.length;
|
|
275
|
+
onProgress({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
|
|
276
|
+
}
|
|
277
|
+
buffer = Buffer.concat(chunks);
|
|
278
|
+
} else {
|
|
279
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
280
|
+
}
|
|
281
|
+
if (type === "buffer") return buffer;
|
|
282
|
+
const text = buffer.toString("utf8");
|
|
283
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
284
|
+
if (type === "json" || (type === "auto" && /[/+]json\b/i.test(contentType))) {
|
|
285
|
+
if (!text) return undefined;
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(text);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (type === "json") throw new HttpError(`Invalid JSON in response from ${res.url}`, { status: res.status, url: res.url, data: text, cause: error });
|
|
290
|
+
return text;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (type === "text" || (type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType))) return text;
|
|
294
|
+
return buffer;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The delay asked for by a `Retry-After` header, in ms.
|
|
299
|
+
* @param {string | null} value
|
|
300
|
+
* @returns {number | undefined}
|
|
301
|
+
*/
|
|
302
|
+
const _retryAfter = (value) => {
|
|
303
|
+
if (!value) return undefined;
|
|
304
|
+
const seconds = Number(value);
|
|
305
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
306
|
+
const date = Date.parse(value);
|
|
307
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The engine behind every method.
|
|
312
|
+
* @param {string | URL} input
|
|
313
|
+
* @param {HttpOptions} options
|
|
314
|
+
* @returns {Promise<HttpResponse<any>>}
|
|
315
|
+
*/
|
|
316
|
+
const _send = async (input, options) => {
|
|
317
|
+
const started = Date.now();
|
|
318
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
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);
|
|
324
|
+
|
|
325
|
+
/** @type {Record<string, string>} */
|
|
326
|
+
const headers = { accept: "application/json, text/plain, */*" };
|
|
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;
|
|
332
|
+
}
|
|
333
|
+
if (options.auth) {
|
|
334
|
+
headers.authorization = "bearer" in options.auth
|
|
335
|
+
? `Bearer ${options.auth.bearer}`
|
|
336
|
+
: `Basic ${Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64")}`;
|
|
337
|
+
}
|
|
338
|
+
/** @type {HttpBody | undefined} */
|
|
339
|
+
let body = options.body;
|
|
340
|
+
if (options.json !== undefined) {
|
|
341
|
+
body = JSON.stringify(options.json);
|
|
342
|
+
headers["content-type"] ??= "application/json";
|
|
343
|
+
} else if (options.form) {
|
|
344
|
+
body = new URLSearchParams(Object.entries(options.form).map(([k, v]) => /** @type {[string, string]} */ ([k, String(v)])));
|
|
345
|
+
}
|
|
346
|
+
|
|
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++) {
|
|
355
|
+
/** @type {HttpRequestConfig} */
|
|
356
|
+
const config = { url: _buildURL(input, options), method, headers: { ...headers }, body, attempt };
|
|
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;
|
|
376
|
+
/** @type {number | undefined} */
|
|
377
|
+
let serverDelay;
|
|
378
|
+
/** @type {Response | undefined} */
|
|
379
|
+
let res;
|
|
380
|
+
/** @type {unknown} */
|
|
381
|
+
let data;
|
|
382
|
+
let streaming = false;
|
|
383
|
+
try {
|
|
384
|
+
const sent = method === "GET" || method === "HEAD" ? undefined : config.body;
|
|
385
|
+
/** @type {any} */
|
|
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);
|
|
402
|
+
}
|
|
403
|
+
} catch (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 {
|
|
411
|
+
/** @type {any} */
|
|
412
|
+
const cause = error;
|
|
413
|
+
/** @type {string} */
|
|
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 });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
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 });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {unknown} data
|
|
465
|
+
* @param {HttpOptions} options
|
|
466
|
+
* @returns {HttpOptions}
|
|
467
|
+
*/
|
|
468
|
+
const _withData = (data, options) => {
|
|
469
|
+
if (data === undefined) return options;
|
|
470
|
+
const plain = data !== null && typeof data === "object" && (Array.isArray(data) || Object.getPrototypeOf(data) === Object.prototype || Object.getPrototypeOf(data) === null);
|
|
471
|
+
return plain || typeof data === "number" || typeof data === "boolean" || data === null ? { ...options, json: data } : { ...options, body: /** @type {HttpBody} */ (data) };
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @param {HttpOptions} defaults
|
|
476
|
+
* @returns {HttpClient}
|
|
477
|
+
*/
|
|
478
|
+
const _client = (defaults) => {
|
|
479
|
+
const merged = (/** @type {HttpOptions | undefined} */ options) => (options ? _mergeOptions(defaults, options) : defaults);
|
|
480
|
+
/** @type {HttpClient} */
|
|
481
|
+
const client = {
|
|
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)),
|
|
490
|
+
extend: (more) => _client(_mergeOptions(defaults, more)),
|
|
491
|
+
defaults: Object.freeze({ ...defaults }),
|
|
492
|
+
};
|
|
493
|
+
return client;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Sends a request and resolves with the response, body already read.
|
|
498
|
+
* Throws an `HttpError` for error statuses, a `TimeoutError` when it's too
|
|
499
|
+
* slow and an `AbortError` when cancelled.
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* const res = await http.request("https://api.example.com/items/42", {
|
|
503
|
+
* method: "PUT",
|
|
504
|
+
* json: { name: "Lamp" },
|
|
505
|
+
* timeout: "10s",
|
|
506
|
+
* retry: { attempts: 5, statuses: [429, 503] },
|
|
507
|
+
* });
|
|
508
|
+
* res.data; // parsed JSON
|
|
509
|
+
*
|
|
510
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
511
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
512
|
+
* @param {string | URL} url An absolute URL, or a path when `baseURL` is set.
|
|
513
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
514
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
515
|
+
* @throws {HttpError | TimeoutError | AbortError}
|
|
516
|
+
*/
|
|
517
|
+
function request(url, options = {}) {
|
|
518
|
+
return _send(url, options);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Sends a GET request.
|
|
523
|
+
*
|
|
524
|
+
* @example
|
|
525
|
+
* const { data } = await http.get("https://api.example.com/users", { query: { page: 2, tags: ["a", "b"] } });
|
|
526
|
+
*
|
|
527
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
528
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
529
|
+
* @param {string | URL} url
|
|
530
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
531
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
532
|
+
*/
|
|
533
|
+
function get(url, options = {}) {
|
|
534
|
+
return _send(url, { ...options, method: "GET" });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Sends a HEAD request: headers only, no body.
|
|
539
|
+
*
|
|
540
|
+
* @example
|
|
541
|
+
* const { headers } = await http.head("https://example.com/big.zip");
|
|
542
|
+
* headers["content-length"];
|
|
543
|
+
*
|
|
544
|
+
* @template [T=undefined]
|
|
545
|
+
* @param {string | URL} url
|
|
546
|
+
* @param {HttpOptions} [options]
|
|
547
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
548
|
+
*/
|
|
549
|
+
function head(url, options = {}) {
|
|
550
|
+
return _send(url, { ...options, method: "HEAD" });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Sends a POST request. Objects, arrays, numbers and booleans go as JSON;
|
|
555
|
+
* strings, Buffers, `FormData`, Blobs and streams go as they are.
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* const { data: user } = await http.post("https://api.example.com/users", { name: "Ada" });
|
|
559
|
+
* await http.post(uploadUrl, formData);
|
|
560
|
+
*
|
|
561
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
562
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
563
|
+
* @param {string | URL} url
|
|
564
|
+
* @param {unknown} [data]
|
|
565
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
566
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
567
|
+
*/
|
|
568
|
+
function post(url, data, options = {}) {
|
|
569
|
+
return _send(url, { ..._withData(data, options), method: "POST" });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Sends a PUT request. The body works like in `post()`.
|
|
574
|
+
*
|
|
575
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
576
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
577
|
+
* @param {string | URL} url
|
|
578
|
+
* @param {unknown} [data]
|
|
579
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
580
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
581
|
+
*/
|
|
582
|
+
function put(url, data, options = {}) {
|
|
583
|
+
return _send(url, { ..._withData(data, options), method: "PUT" });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Sends a PATCH request. The body works like in `post()`.
|
|
588
|
+
*
|
|
589
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
590
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
591
|
+
* @param {string | URL} url
|
|
592
|
+
* @param {unknown} [data]
|
|
593
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
594
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
595
|
+
*/
|
|
596
|
+
function patch(url, data, options = {}) {
|
|
597
|
+
return _send(url, { ..._withData(data, options), method: "PATCH" });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Sends a DELETE request.
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* await http.delete(`https://api.example.com/users/${id}`);
|
|
605
|
+
*
|
|
606
|
+
* @template [T=any] The type of `data`, when no `schema` says it.
|
|
607
|
+
* @template {ResponseSchema} [S={ parse(data: unknown): T }]
|
|
608
|
+
* @param {string | URL} url
|
|
609
|
+
* @param {HttpOptionsOf<S>} [options]
|
|
610
|
+
* @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
|
|
611
|
+
*/
|
|
612
|
+
function del(url, options = {}) {
|
|
613
|
+
return _send(url, { ...options, method: "DELETE" });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Downloads to a file as a stream, so memory stays flat whatever the size.
|
|
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.
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* await http.download("https://example.com/video.mp4", "./downloads/video.mp4", {
|
|
627
|
+
* onDownloadProgress: ({ percent }) => bar.update(percent ?? 0),
|
|
628
|
+
* });
|
|
629
|
+
*
|
|
630
|
+
* @param {string | URL} url
|
|
631
|
+
* @param {string} destination
|
|
632
|
+
* @param {HttpOptions} [options]
|
|
633
|
+
* @returns {Promise<{ path: string, size: number }>} The absolute path and size in bytes.
|
|
634
|
+
* @throws {HttpError | TimeoutError | AbortError}
|
|
635
|
+
*/
|
|
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");
|
|
641
|
+
const target = nodePath.resolve(destination);
|
|
642
|
+
fs.mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
643
|
+
const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined });
|
|
644
|
+
const total = Number(res.headers["content-length"]) || undefined;
|
|
645
|
+
const partial = `${target}.${process.pid}-${Date.now().toString(36)}.part`;
|
|
646
|
+
let size = 0;
|
|
647
|
+
const counter = new Transform({
|
|
648
|
+
transform(chunk, _encoding, callback) {
|
|
649
|
+
size += chunk.length;
|
|
650
|
+
options.onDownloadProgress?.({ loaded: size, total, percent: total ? Math.min(100, (size / total) * 100) : undefined });
|
|
651
|
+
callback(null, chunk);
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
try {
|
|
655
|
+
await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, fs.createWriteStream(partial), { signal: options.signal });
|
|
656
|
+
fs.renameSync(partial, target);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
fs.rmSync(partial, { force: true });
|
|
659
|
+
if (options.signal?.aborted) throw new AbortError(`Download aborted: ${String(url)}`, { cause: error });
|
|
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 });
|
|
665
|
+
}
|
|
666
|
+
return { path: target, size };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Creates a client with default options. Options passed to each call are
|
|
671
|
+
* merged on top.
|
|
672
|
+
*
|
|
673
|
+
* @example
|
|
674
|
+
* const github = http.create({
|
|
675
|
+
* baseURL: "https://api.github.com",
|
|
676
|
+
* auth: { bearer: process.env.GITHUB_TOKEN },
|
|
677
|
+
* hooks: { afterResponse: [(res) => nc.debug(res.status, res.url)] },
|
|
678
|
+
* });
|
|
679
|
+
* const { data: repo } = await github.get("/repos/nodejs/node");
|
|
680
|
+
*
|
|
681
|
+
* @param {HttpOptions} [defaults]
|
|
682
|
+
* @returns {HttpClient}
|
|
683
|
+
*/
|
|
684
|
+
function create(defaults = {}) {
|
|
685
|
+
return _client(defaults);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
module.exports = {
|
|
689
|
+
request,
|
|
690
|
+
// not shorthand: Node's ES module export detection stops at a bare `get`
|
|
691
|
+
get: get,
|
|
692
|
+
head,
|
|
693
|
+
post,
|
|
694
|
+
put,
|
|
695
|
+
patch,
|
|
696
|
+
delete: del,
|
|
697
|
+
download,
|
|
698
|
+
create,
|
|
699
|
+
HttpError,
|
|
700
|
+
};
|