@ix-xs/node-comfort 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/Arr.js +13 -5
- package/src/Checker.js +56 -8
- package/src/Cli.js +60 -18
- package/src/Color.js +32 -6
- package/src/Crypto.js +429 -78
- package/src/Emitter.js +9 -9
- package/src/Env.js +90 -8
- package/src/FS.js +369 -59
- package/src/Func.js +1 -1
- package/src/Http.js +266 -75
- package/src/Id.js +53 -15
- package/src/Logger.js +100 -22
- package/src/Num.js +3 -2
- package/src/Obj.js +225 -36
- package/src/SQLite.js +155 -98
- package/src/Schema.js +281 -40
- package/src/Str.js +77 -29
- package/src/Sys.js +124 -23
- package/src/Time.js +98 -24
- package/src/Utils.js +18 -3
- package/src/errors.js +75 -4
- package/src/internal/shared.js +68 -14
- package/src/internal/terminal.js +49 -0
- package/types/index.d.ts +28 -5
- package/types/src/Arr.d.ts +4 -2
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Checker.d.ts +29 -3
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +6 -4
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +2 -2
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +126 -28
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +6 -6
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +19 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +30 -23
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +1 -1
- package/types/src/Http.d.ts +45 -8
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +11 -3
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +1 -1
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +15 -2
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +105 -13
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +61 -5
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +32 -7
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +43 -10
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +12 -7
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +16 -6
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +6 -2
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +7 -0
- package/types/src/internal/shared.d.ts.map +1 -1
- package/types/src/internal/terminal.d.ts +13 -0
- package/types/src/internal/terminal.d.ts.map +1 -0
package/src/Http.js
CHANGED
|
@@ -70,9 +70,10 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
|
|
|
70
70
|
* Request options.
|
|
71
71
|
* @typedef {object} HttpOptions
|
|
72
72
|
* @property {HttpMethod} [method] Defaults to `"GET"`.
|
|
73
|
-
* @property {string} [baseURL] Prefix for relative URLs.
|
|
73
|
+
* @property {string} [baseURL] Prefix for relative URLs. It's a boundary: a path that would leave it, like `"../admin"`, `"//other.host"` or an absolute URL to another place, throws a `TypeError` (see `allowAbsoluteUrls`).
|
|
74
|
+
* @property {boolean} [allowAbsoluteUrls] With a `baseURL`, let an absolute URL go elsewhere: `api.get("https://cdn.example.com/x", { allowAbsoluteUrls: true })`. The client's own `auth`, `headers` and `query` still stay with the base origin, only those of the call go there. Defaults to `false`, so a URL built from outside input can't take the client's credentials to another host.
|
|
74
75
|
* @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 {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, and `"."` or `".."` throws a `TypeError`, since they would move up the path.
|
|
76
77
|
* @property {QueryParams | URLSearchParams} [query] Added to the URL's own query string, and to the client's default query.
|
|
77
78
|
* @property {unknown} [json] Sent as JSON.
|
|
78
79
|
* @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
|
|
@@ -86,7 +87,9 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
|
|
|
86
87
|
* @property {AbortSignal} [signal] Cancels the request.
|
|
87
88
|
* @property {HttpHooks} [hooks]
|
|
88
89
|
* @property {(progress: { loaded: number, total: number | undefined, percent: number | undefined }) => void} [onDownloadProgress] Called while the body downloads.
|
|
89
|
-
* @property {
|
|
90
|
+
* @property {number | string} [maxResponseSize] Largest body read into memory, in bytes or like `"50MB"`, counted once decompressed. Past it, the request is cancelled and an `HttpError` with the code `ERR_RESPONSE_TOO_LARGE` is thrown, so a huge or zip bomb answer can't fill the memory. Defaults to `"100MB"`, `Infinity` turns it off. A stream (`responseType: "stream"`) and the file of `download()` aren't held in memory, so they aren't limited.
|
|
91
|
+
* @property {"follow" | "error" | "manual"} [redirect] `"follow"` (the default) follows up to 20 redirects, `"manual"` returns the 3xx response as it is, `"error"` throws an `HttpError` with the code `ERR_REDIRECT`. When a redirect goes to another origin, `authorization`, `cookie`, `proxy-authorization` and the `sensitiveHeaders` are left behind.
|
|
92
|
+
* @property {string[]} [sensitiveHeaders] Headers dropped when a redirect goes to another origin, on top of `authorization`, `cookie` and `proxy-authorization`. Names are case-insensitive. Defaults to `["x-api-key", "x-auth-token", "api-key"]`; a list you give replaces it, so add these to it if you use them.
|
|
90
93
|
*/
|
|
91
94
|
|
|
92
95
|
/**
|
|
@@ -123,9 +126,9 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
|
|
|
123
126
|
* @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
127
|
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} head Sends a HEAD request.
|
|
125
128
|
* @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
|
|
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
|
|
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
|
|
129
|
+
* @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, arrays, numbers and booleans go as JSON.
|
|
130
|
+
* @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, arrays, numbers and booleans go as JSON.
|
|
131
|
+
* @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, arrays, numbers and booleans go as JSON.
|
|
129
132
|
* @property {(url: string | URL, destination: string, options?: HttpOptions) => Promise<{ path: string, size: number }>} download Saves a response to a file.
|
|
130
133
|
* @property {(defaults: HttpOptions) => HttpClient} extend A new client with these defaults added.
|
|
131
134
|
* @property {Readonly<HttpOptions>} defaults
|
|
@@ -134,7 +137,19 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
|
|
|
134
137
|
const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
|
|
135
138
|
const RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
136
139
|
const DEFAULT_TIMEOUT = 30_000;
|
|
140
|
+
const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
|
|
141
|
+
// What an error body gets when a stream was asked for: enough to tell what went wrong.
|
|
142
|
+
const ERROR_BODY_LIMIT = 1024 * 1024;
|
|
143
|
+
const MAX_REDIRECTS = 20;
|
|
144
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
145
|
+
// Always left behind when a redirect changes origin, whatever `sensitiveHeaders` says.
|
|
146
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
147
|
+
const SENSITIVE_HEADERS = ["x-api-key", "x-auth-token", "api-key"];
|
|
148
|
+
// What describes a body, dropped with it when a redirect turns the request into a GET.
|
|
149
|
+
const BODY_HEADERS = ["content-type", "content-length", "content-encoding", "content-language", "content-location"];
|
|
137
150
|
const ABSOLUTE = /^[a-z][a-z\d+.-]*:/i;
|
|
151
|
+
// The URL parser skips these at the start and reads "\" as "/", so " //host" or "\\host" would leave the base URL.
|
|
152
|
+
const LEADING = /^[\s\x00-\x1f/\\]+/;
|
|
138
153
|
// ":name" right after a slash, which leaves "http://host:3000" alone
|
|
139
154
|
const PARAM = /(?<=\/):([A-Za-z_]\w*)/g;
|
|
140
155
|
|
|
@@ -194,6 +209,13 @@ const _appendQuery = (params, query) => {
|
|
|
194
209
|
}
|
|
195
210
|
};
|
|
196
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A URL for an error message: without its query, which can hold a key.
|
|
214
|
+
* @param {unknown} url
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
const _shown = (url) => String(url).replace(/[?#][\s\S]*$/, "");
|
|
218
|
+
|
|
197
219
|
/**
|
|
198
220
|
* @template T
|
|
199
221
|
* @param {T[] | undefined} a
|
|
@@ -229,12 +251,25 @@ const _mergeOptions = (base, extra) => {
|
|
|
229
251
|
};
|
|
230
252
|
|
|
231
253
|
/**
|
|
254
|
+
* The URL a request goes to, query not added yet. With a `baseURL`, it has
|
|
255
|
+
* to stay under it: same origin, same path prefix. Only an absolute URL
|
|
256
|
+
* passed with `allowAbsoluteUrls` may go elsewhere, and then `foreign` says
|
|
257
|
+
* it left the base origin.
|
|
232
258
|
* @param {string | URL} input
|
|
233
259
|
* @param {HttpOptions} options
|
|
234
|
-
* @returns {
|
|
235
|
-
*/
|
|
236
|
-
const
|
|
260
|
+
* @returns {{ url: URL, foreign: boolean }}
|
|
261
|
+
*/
|
|
262
|
+
const _resolve = (input, options) => {
|
|
263
|
+
let base;
|
|
264
|
+
if (options.baseURL) {
|
|
265
|
+
// One slash at the end, so that relative paths go under it.
|
|
266
|
+
const text = String(options.baseURL);
|
|
267
|
+
let end = text.length;
|
|
268
|
+
while (end > 0 && text[end - 1] === "/") end--;
|
|
269
|
+
base = new URL(`${text.slice(0, end)}/`);
|
|
270
|
+
}
|
|
237
271
|
let url;
|
|
272
|
+
let absolute = true;
|
|
238
273
|
if (typeof input !== "string") {
|
|
239
274
|
url = new URL(String(input));
|
|
240
275
|
} else {
|
|
@@ -242,42 +277,126 @@ const _buildURL = (input, options) => {
|
|
|
242
277
|
const path = !params ? input : input.replace(PARAM, (_, name) => {
|
|
243
278
|
const value = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
244
279
|
if (value === undefined || value === null) throw new TypeError(`Missing URL parameter "${name}" for ${input}`);
|
|
245
|
-
|
|
280
|
+
const text = String(value);
|
|
281
|
+
// Encoding can't help: the URL parser reads "%2e%2e" as "..".
|
|
282
|
+
if (text === "." || text === "..") throw new TypeError(`URL parameter "${name}" can't be "${text}" for ${input}: it would move up the path`);
|
|
283
|
+
return encodeURIComponent(text);
|
|
246
284
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
285
|
+
absolute = ABSOLUTE.test(path);
|
|
286
|
+
if (!base) {
|
|
287
|
+
if (!absolute) throw new TypeError(`Invalid URL "${input}": use an absolute URL or set baseURL`);
|
|
288
|
+
url = new URL(path);
|
|
289
|
+
} else {
|
|
290
|
+
url = absolute && options.allowAbsoluteUrls ? new URL(path) : new URL(path.replace(LEADING, ""), base);
|
|
291
|
+
}
|
|
250
292
|
}
|
|
251
|
-
|
|
252
|
-
|
|
293
|
+
if (!base) return { url, foreign: false };
|
|
294
|
+
// The base path ends with "/", so the prefix stops at a segment: "/v1/" takes "/v1" and "/v1/x", not "/v10".
|
|
295
|
+
if (url.origin === base.origin && (url.pathname.startsWith(base.pathname) || `${url.pathname}/` === base.pathname)) return { url, foreign: false };
|
|
296
|
+
if (absolute && options.allowAbsoluteUrls) return { url, foreign: url.origin !== base.origin };
|
|
297
|
+
throw new TypeError(`URL "${String(input)}" leaves the base URL ${base.href}: pass allowAbsoluteUrls: true to send an absolute URL elsewhere on purpose`);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {number | string | undefined} value
|
|
302
|
+
* @returns {number}
|
|
303
|
+
*/
|
|
304
|
+
const _maxSize = (value) => {
|
|
305
|
+
if (value === undefined) return DEFAULT_MAX_RESPONSE_SIZE;
|
|
306
|
+
const bytes = typeof value === "number" ? value : require("./Num.js").parseBytes(value);
|
|
307
|
+
if (!(bytes >= 0)) throw new TypeError(`Invalid maxResponseSize: ${String(value)}`);
|
|
308
|
+
return bytes;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The headers of a request, from its options, with lower-cased names.
|
|
313
|
+
* @param {HttpOptions} options
|
|
314
|
+
* @returns {Record<string, string>}
|
|
315
|
+
*/
|
|
316
|
+
const _headersOf = (options) => {
|
|
317
|
+
/** @type {Record<string, string>} */
|
|
318
|
+
const headers = { accept: "application/json, text/plain, */*" };
|
|
319
|
+
const given = _headerRecord(options.headers);
|
|
320
|
+
for (const key of Object.keys(given)) {
|
|
321
|
+
const value = given[key];
|
|
322
|
+
if (value === undefined) delete headers[key];
|
|
323
|
+
else headers[key] = value;
|
|
324
|
+
}
|
|
325
|
+
if (options.auth) {
|
|
326
|
+
headers.authorization = "bearer" in options.auth
|
|
327
|
+
? `Bearer ${options.auth.bearer}`
|
|
328
|
+
: `Basic ${Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64")}`;
|
|
329
|
+
}
|
|
330
|
+
return headers;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @param {Record<string, string>} headers Changed in place.
|
|
335
|
+
* @param {Iterable<string>} names Lower-cased.
|
|
336
|
+
*/
|
|
337
|
+
const _dropHeaders = (headers, names) => {
|
|
338
|
+
const drop = new Set(names);
|
|
339
|
+
// A hook may have set a header with capitals in its name.
|
|
340
|
+
for (const key of Object.keys(headers)) if (drop.has(key.toLowerCase())) delete headers[key];
|
|
253
341
|
};
|
|
254
342
|
|
|
255
343
|
/** @param {unknown} body */
|
|
256
344
|
const _isStream = (body) =>
|
|
257
345
|
body !== null && typeof body === "object" && (typeof (/** @type {any} */ (body).pipe) === "function" || typeof (/** @type {any} */ (body).getReader) === "function");
|
|
258
346
|
|
|
347
|
+
/**
|
|
348
|
+
* How `_readBody()` reads.
|
|
349
|
+
* @typedef {object} _ReadSettings
|
|
350
|
+
* @property {HttpOptions["responseType"]} type
|
|
351
|
+
* @property {HttpOptions["onDownloadProgress"]} onProgress
|
|
352
|
+
* @property {number} limit Most bytes kept, once decompressed.
|
|
353
|
+
* @property {boolean} truncate Past the limit, keep the start instead of throwing.
|
|
354
|
+
* @property {string} method For the error message.
|
|
355
|
+
* @property {() => void} abort Cancels the request.
|
|
356
|
+
*/
|
|
357
|
+
|
|
259
358
|
/**
|
|
260
359
|
* @param {Response} res
|
|
261
|
-
* @param {
|
|
262
|
-
* @param {HttpOptions["onDownloadProgress"]} onProgress
|
|
360
|
+
* @param {_ReadSettings} settings
|
|
263
361
|
* @returns {Promise<unknown>}
|
|
264
362
|
*/
|
|
265
|
-
const _readBody = async (res, type = "auto", onProgress) => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
363
|
+
const _readBody = async (res, { type = "auto", onProgress, limit, truncate, method, abort }) => {
|
|
364
|
+
const total = Number(res.headers.get("content-length")) || undefined;
|
|
365
|
+
const tooLarge = () => {
|
|
366
|
+
abort();
|
|
367
|
+
return new HttpError(`Response larger than the ${limit} bytes allowed by maxResponseSize: ${method} ${res.url}`, {
|
|
368
|
+
status: res.status, statusText: res.statusText, url: res.url, method, headers: Object.fromEntries(res.headers), code: "ERR_RESPONSE_TOO_LARGE",
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
if (!truncate && total !== undefined && total > limit) throw tooLarge();
|
|
372
|
+
/** @type {Buffer[]} */
|
|
373
|
+
const chunks = [];
|
|
374
|
+
let loaded = 0;
|
|
375
|
+
if (res.body) {
|
|
376
|
+
// Read piece by piece and count what comes out of the decompression, so a zip bomb stops at the limit.
|
|
377
|
+
const reader = res.body.getReader();
|
|
378
|
+
try {
|
|
379
|
+
for (;;) {
|
|
380
|
+
const { done, value } = await reader.read();
|
|
381
|
+
if (done) break;
|
|
382
|
+
if (loaded + value.byteLength > limit) {
|
|
383
|
+
if (!truncate) throw tooLarge();
|
|
384
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, limit - loaded));
|
|
385
|
+
loaded = limit;
|
|
386
|
+
reader.cancel().catch(() => {});
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
|
|
390
|
+
loaded += value.byteLength;
|
|
391
|
+
onProgress?.({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
|
|
392
|
+
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
// A callback that throws or a failed read must not leave the connection open.
|
|
395
|
+
reader.cancel().catch(() => {});
|
|
396
|
+
throw error;
|
|
276
397
|
}
|
|
277
|
-
buffer = Buffer.concat(chunks);
|
|
278
|
-
} else {
|
|
279
|
-
buffer = Buffer.from(await res.arrayBuffer());
|
|
280
398
|
}
|
|
399
|
+
const buffer = Buffer.concat(chunks, loaded);
|
|
281
400
|
if (type === "buffer") return buffer;
|
|
282
401
|
const text = buffer.toString("utf8");
|
|
283
402
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -307,13 +426,60 @@ const _retryAfter = (value) => {
|
|
|
307
426
|
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
308
427
|
};
|
|
309
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Sends one attempt and follows its redirects, like `fetch` would, but
|
|
431
|
+
* without taking secret headers to another origin.
|
|
432
|
+
* @param {HttpRequestConfig} config
|
|
433
|
+
* @param {{ method: string, body: HttpBody | undefined, redirect: NonNullable<HttpOptions["redirect"]>, sensitive: string[], signal: AbortSignal }} settings
|
|
434
|
+
* @returns {Promise<Response>}
|
|
435
|
+
*/
|
|
436
|
+
const _follow = async (config, { method, body, redirect, sensitive, signal }) => {
|
|
437
|
+
let url = config.url;
|
|
438
|
+
const headers = { ...config.headers };
|
|
439
|
+
for (let hops = 0; ; hops++) {
|
|
440
|
+
/** @type {any} */
|
|
441
|
+
const init = { method, headers, body, signal, redirect: "manual" };
|
|
442
|
+
if (_isStream(body)) init.duplex = "half";
|
|
443
|
+
const res = await fetch(url, init);
|
|
444
|
+
const location = redirect !== "manual" && REDIRECT_STATUSES.has(res.status) ? res.headers.get("location") : null;
|
|
445
|
+
if (location === null) return res;
|
|
446
|
+
res.body?.cancel().catch(() => {});
|
|
447
|
+
/** @param {string} why */
|
|
448
|
+
const fail = (why) => new HttpError(`${why}: ${config.method} ${config.url}`, {
|
|
449
|
+
status: res.status, statusText: res.statusText, url, method: config.method, headers: Object.fromEntries(res.headers), code: hops >= MAX_REDIRECTS ? "ERR_TOO_MANY_REDIRECTS" : "ERR_REDIRECT",
|
|
450
|
+
});
|
|
451
|
+
if (redirect === "error") throw fail(`Redirected with status ${res.status} while redirect is "error"`);
|
|
452
|
+
if (hops >= MAX_REDIRECTS) throw fail(`More than ${MAX_REDIRECTS} redirects`);
|
|
453
|
+
/** @type {URL} */
|
|
454
|
+
let next;
|
|
455
|
+
try {
|
|
456
|
+
next = new URL(location, url);
|
|
457
|
+
} catch {
|
|
458
|
+
throw fail(`Redirected to an invalid URL "${location}"`);
|
|
459
|
+
}
|
|
460
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") throw fail(`Redirected to a ${next.protocol} URL`);
|
|
461
|
+
if (((res.status === 301 || res.status === 302) && method === "POST") || (res.status === 303 && method !== "GET" && method !== "HEAD")) {
|
|
462
|
+
// What fetch does: the redirect is fetched with GET, without the body.
|
|
463
|
+
method = "GET";
|
|
464
|
+
body = undefined;
|
|
465
|
+
_dropHeaders(headers, BODY_HEADERS);
|
|
466
|
+
} else if (_isStream(body)) {
|
|
467
|
+
throw fail(`Can't follow the ${res.status} redirect: the body is a stream, already sent`);
|
|
468
|
+
}
|
|
469
|
+
// Another origin (an https to http downgrade is one too) gets no credentials.
|
|
470
|
+
if (next.origin !== new URL(url).origin) _dropHeaders(headers, sensitive);
|
|
471
|
+
url = next.href;
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
310
475
|
/**
|
|
311
476
|
* The engine behind every method.
|
|
312
477
|
* @param {string | URL} input
|
|
313
|
-
* @param {HttpOptions} options
|
|
478
|
+
* @param {HttpOptions} options Everything, the client's defaults included.
|
|
479
|
+
* @param {HttpOptions} [own] Only what the call gave, when it comes from a client.
|
|
314
480
|
* @returns {Promise<HttpResponse<any>>}
|
|
315
481
|
*/
|
|
316
|
-
const _send = async (input, options) => {
|
|
482
|
+
const _send = async (input, options, own) => {
|
|
317
483
|
const started = Date.now();
|
|
318
484
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
319
485
|
const signal = options.signal;
|
|
@@ -321,20 +487,16 @@ const _send = async (input, options) => {
|
|
|
321
487
|
const retry = typeof options.retry === "object" && options.retry !== null ? options.retry : {};
|
|
322
488
|
const statuses = retry.statuses ?? RETRY_STATUSES;
|
|
323
489
|
const timeoutMs = _timeout(options.timeout);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
}
|
|
490
|
+
const maxSize = _maxSize(options.maxResponseSize);
|
|
491
|
+
const redirect = options.redirect ?? "follow";
|
|
492
|
+
const sensitive = [...CREDENTIAL_HEADERS, ...(options.sensitiveHeaders ?? SENSITIVE_HEADERS).map((name) => String(name).toLowerCase())];
|
|
493
|
+
|
|
494
|
+
const { url: target, foreign } = _resolve(input, options);
|
|
495
|
+
// Away from the base origin, the client's credentials, headers and query stay home: only the call's go.
|
|
496
|
+
const scoped = foreign && own ? own : options;
|
|
497
|
+
_appendQuery(target.searchParams, scoped.query);
|
|
498
|
+
const href = target.href;
|
|
499
|
+
const headers = _headersOf(scoped);
|
|
338
500
|
/** @type {HttpBody | undefined} */
|
|
339
501
|
let body = options.body;
|
|
340
502
|
if (options.json !== undefined) {
|
|
@@ -353,9 +515,9 @@ const _send = async (input, options) => {
|
|
|
353
515
|
|
|
354
516
|
for (let attempt = 1; ; attempt++) {
|
|
355
517
|
/** @type {HttpRequestConfig} */
|
|
356
|
-
const config = { url:
|
|
518
|
+
const config = { url: href, method, headers: { ...headers }, body, attempt };
|
|
357
519
|
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 });
|
|
520
|
+
if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${_shown(config.url)}`, { cause: signal.reason });
|
|
359
521
|
|
|
360
522
|
const controller = new AbortController();
|
|
361
523
|
let timedOut = false;
|
|
@@ -381,12 +543,11 @@ const _send = async (input, options) => {
|
|
|
381
543
|
let data;
|
|
382
544
|
let streaming = false;
|
|
383
545
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// An error status is read whole, so the error can show what the server said.
|
|
546
|
+
res = await _follow(config, {
|
|
547
|
+
method, redirect, sensitive, signal: controller.signal,
|
|
548
|
+
body: method === "GET" || method === "HEAD" ? undefined : config.body,
|
|
549
|
+
});
|
|
550
|
+
// An error status is read, so the error can show what the server said.
|
|
390
551
|
streaming = options.responseType === "stream" && (res.ok || options.throwHttpErrors === false);
|
|
391
552
|
if (method === "HEAD") {
|
|
392
553
|
data = undefined;
|
|
@@ -398,13 +559,22 @@ const _send = async (input, options) => {
|
|
|
398
559
|
stream.once("close", release);
|
|
399
560
|
data = stream;
|
|
400
561
|
} else {
|
|
401
|
-
|
|
562
|
+
// An error answer to a stream request: only its start, the rest could be any size.
|
|
563
|
+
const errorOnly = options.responseType === "stream";
|
|
564
|
+
data = await _readBody(res, {
|
|
565
|
+
type: errorOnly ? "auto" : options.responseType,
|
|
566
|
+
onProgress: options.onDownloadProgress,
|
|
567
|
+
limit: errorOnly ? Math.min(ERROR_BODY_LIMIT, maxSize) : maxSize,
|
|
568
|
+
truncate: errorOnly,
|
|
569
|
+
method,
|
|
570
|
+
abort: () => controller.abort(),
|
|
571
|
+
});
|
|
402
572
|
}
|
|
403
573
|
} catch (error) {
|
|
404
574
|
release();
|
|
405
|
-
if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal.reason });
|
|
575
|
+
if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${_shown(config.url)}`, { cause: signal.reason });
|
|
406
576
|
if (timedOut) {
|
|
407
|
-
failure = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${config.url}`, { timeout: timeoutMs, cause: error });
|
|
577
|
+
failure = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${_shown(config.url)}`, { timeout: timeoutMs, cause: error });
|
|
408
578
|
} else if (error instanceof HttpError) {
|
|
409
579
|
throw error;
|
|
410
580
|
} else {
|
|
@@ -455,7 +625,7 @@ const _send = async (input, options) => {
|
|
|
455
625
|
try {
|
|
456
626
|
await sleep(wait, signal);
|
|
457
627
|
} catch (error) {
|
|
458
|
-
throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal?.reason ?? error });
|
|
628
|
+
throw new AbortError(`Request aborted: ${method} ${_shown(config.url)}`, { cause: signal?.reason ?? error });
|
|
459
629
|
}
|
|
460
630
|
}
|
|
461
631
|
};
|
|
@@ -479,14 +649,15 @@ const _client = (defaults) => {
|
|
|
479
649
|
const merged = (/** @type {HttpOptions | undefined} */ options) => (options ? _mergeOptions(defaults, options) : defaults);
|
|
480
650
|
/** @type {HttpClient} */
|
|
481
651
|
const client = {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
652
|
+
// The call's own options go along too: they alone may leave the base origin.
|
|
653
|
+
request: (url, options) => _send(url, merged(options), options ?? {}),
|
|
654
|
+
get: (url, options) => _send(url, { ...merged(options), method: "GET" }, options ?? {}),
|
|
655
|
+
head: (url, options) => _send(url, { ...merged(options), method: "HEAD" }, options ?? {}),
|
|
656
|
+
delete: (url, options) => _send(url, { ...merged(options), method: "DELETE" }, options ?? {}),
|
|
657
|
+
post: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "POST" }, options ?? {}),
|
|
658
|
+
put: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PUT" }, options ?? {}),
|
|
659
|
+
patch: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PATCH" }, options ?? {}),
|
|
660
|
+
download: (url, destination, options) => _download(url, destination, merged(options), options ?? {}),
|
|
490
661
|
extend: (more) => _client(_mergeOptions(defaults, more)),
|
|
491
662
|
defaults: Object.freeze({ ...defaults }),
|
|
492
663
|
};
|
|
@@ -617,7 +788,9 @@ function del(url, options = {}) {
|
|
|
617
788
|
* Downloads to a file as a stream, so memory stays flat whatever the size.
|
|
618
789
|
* Folders are created. The data goes to a temporary file that takes the
|
|
619
790
|
* final name once complete, so a failed or interrupted download never
|
|
620
|
-
* leaves half a file where you expect a whole one.
|
|
791
|
+
* leaves half a file where you expect a whole one. That temporary file has
|
|
792
|
+
* a random name and is only readable and writable by you (mode `0o600`),
|
|
793
|
+
* which the final file keeps. `maxResponseSize` doesn't apply here.
|
|
621
794
|
*
|
|
622
795
|
* `timeout` is how long the server gets to start answering; the transfer
|
|
623
796
|
* itself can take as long as it needs. Use `signal` to put a limit on it.
|
|
@@ -633,16 +806,28 @@ function del(url, options = {}) {
|
|
|
633
806
|
* @returns {Promise<{ path: string, size: number }>} The absolute path and size in bytes.
|
|
634
807
|
* @throws {HttpError | TimeoutError | AbortError}
|
|
635
808
|
*/
|
|
636
|
-
|
|
809
|
+
function download(url, destination, options = {}) {
|
|
810
|
+
return _download(url, destination, options);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* @param {string | URL} url
|
|
815
|
+
* @param {string} destination
|
|
816
|
+
* @param {HttpOptions} options
|
|
817
|
+
* @param {HttpOptions} [own] Only what the call gave, when it comes from a client.
|
|
818
|
+
* @returns {Promise<{ path: string, size: number }>}
|
|
819
|
+
*/
|
|
820
|
+
const _download = async (url, destination, options, own) => {
|
|
637
821
|
const fs = require("node:fs");
|
|
638
822
|
const nodePath = require("node:path");
|
|
639
823
|
const { Transform } = require("node:stream");
|
|
640
824
|
const { pipeline } = require("node:stream/promises");
|
|
641
825
|
const target = nodePath.resolve(destination);
|
|
642
826
|
fs.mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
643
|
-
const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined });
|
|
827
|
+
const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined }, own);
|
|
644
828
|
const total = Number(res.headers["content-length"]) || undefined;
|
|
645
|
-
|
|
829
|
+
// A name nobody can guess, created by us alone ("wx"): a link planted there can't make us write elsewhere.
|
|
830
|
+
const partial = `${target}.${require("node:crypto").randomBytes(8).toString("hex")}.part`;
|
|
646
831
|
let size = 0;
|
|
647
832
|
const counter = new Transform({
|
|
648
833
|
transform(chunk, _encoding, callback) {
|
|
@@ -651,12 +836,18 @@ async function download(url, destination, options = {}) {
|
|
|
651
836
|
callback(null, chunk);
|
|
652
837
|
},
|
|
653
838
|
});
|
|
839
|
+
const file = fs.createWriteStream(partial, { flags: "wx", mode: 0o600 });
|
|
840
|
+
let created = false;
|
|
841
|
+
file.once("open", () => {
|
|
842
|
+
created = true;
|
|
843
|
+
});
|
|
654
844
|
try {
|
|
655
|
-
await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter,
|
|
845
|
+
await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, file, { signal: options.signal });
|
|
656
846
|
fs.renameSync(partial, target);
|
|
657
847
|
} catch (error) {
|
|
658
|
-
|
|
659
|
-
if (
|
|
848
|
+
// A file that was already there isn't ours to remove.
|
|
849
|
+
if (created) fs.rmSync(partial, { force: true });
|
|
850
|
+
if (options.signal?.aborted) throw new AbortError(`Download aborted: ${_shown(url)}`, { cause: error });
|
|
660
851
|
/** @type {any} */
|
|
661
852
|
const cause = error;
|
|
662
853
|
// What went wrong on the disk names a file and stays as it is; the rest is the connection.
|
|
@@ -664,7 +855,7 @@ async function download(url, destination, options = {}) {
|
|
|
664
855
|
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
856
|
}
|
|
666
857
|
return { path: target, size };
|
|
667
|
-
}
|
|
858
|
+
};
|
|
668
859
|
|
|
669
860
|
/**
|
|
670
861
|
* Creates a client with default options. Options passed to each call are
|
package/src/Id.js
CHANGED
|
@@ -77,6 +77,44 @@ const POOL_SIZE = 4096;
|
|
|
77
77
|
/** @type {Buffer | undefined} */
|
|
78
78
|
let _pool;
|
|
79
79
|
let _poolAt = POOL_SIZE;
|
|
80
|
+
// `process` is a getter on the global object: a local copy is faster to read.
|
|
81
|
+
const _process = process;
|
|
82
|
+
// The process that filled the pool. Another one (a fork, or a process started
|
|
83
|
+
// from a startup snapshot) must not hand out the same bytes again.
|
|
84
|
+
let _poolPid = -1;
|
|
85
|
+
|
|
86
|
+
// State of the generators that count up within a process.
|
|
87
|
+
let _v7LastMs = 0;
|
|
88
|
+
let _v7Counter = 0;
|
|
89
|
+
let _v7Head = "";
|
|
90
|
+
let _ulidLastMs = -1;
|
|
91
|
+
let _ulidHead = "";
|
|
92
|
+
const _ulidRandom = new Uint8Array(16);
|
|
93
|
+
let _oidProcess = "";
|
|
94
|
+
let _oidCounter = 0;
|
|
95
|
+
|
|
96
|
+
/** Forgets the pool and the generator state, as in a new process. */
|
|
97
|
+
const _reset = () => {
|
|
98
|
+
_poolAt = POOL_SIZE;
|
|
99
|
+
_poolPid = -1;
|
|
100
|
+
_v7LastMs = 0;
|
|
101
|
+
_v7Counter = 0;
|
|
102
|
+
_v7Head = "";
|
|
103
|
+
_ulidLastMs = -1;
|
|
104
|
+
_ulidHead = "";
|
|
105
|
+
_oidProcess = "";
|
|
106
|
+
_oidCounter = 0;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Refills the pool, and starts over first when the process changed. */
|
|
110
|
+
const _refill = () => {
|
|
111
|
+
if (_poolPid !== _process.pid) {
|
|
112
|
+
_reset();
|
|
113
|
+
_poolPid = _process.pid;
|
|
114
|
+
}
|
|
115
|
+
_crypto().randomFillSync((_pool ??= Buffer.allocUnsafeSlow(POOL_SIZE)));
|
|
116
|
+
_poolAt = 0;
|
|
117
|
+
};
|
|
80
118
|
|
|
81
119
|
/**
|
|
82
120
|
* Reserves `count` fresh random bytes in the pool.
|
|
@@ -84,15 +122,24 @@ let _poolAt = POOL_SIZE;
|
|
|
84
122
|
* @returns {number} Where they start.
|
|
85
123
|
*/
|
|
86
124
|
const _take = (count) => {
|
|
87
|
-
if (_poolAt + count > POOL_SIZE)
|
|
88
|
-
_crypto().randomFillSync((_pool ??= Buffer.allocUnsafeSlow(POOL_SIZE)));
|
|
89
|
-
_poolAt = 0;
|
|
90
|
-
}
|
|
125
|
+
if (_poolAt + count > POOL_SIZE || _poolPid !== _process.pid) _refill();
|
|
91
126
|
const at = _poolAt;
|
|
92
127
|
_poolAt += count;
|
|
93
128
|
return at;
|
|
94
129
|
};
|
|
95
130
|
|
|
131
|
+
// A startup snapshot (`node --build-snapshot`, single executable apps) would
|
|
132
|
+
// freeze the pool and the state, so every process started from it would print
|
|
133
|
+
// the same ids. The pid check above misses a reused pid, this doesn't.
|
|
134
|
+
const _snapshot = require("node:v8").startupSnapshot;
|
|
135
|
+
if (_snapshot?.isBuildingSnapshot?.()) {
|
|
136
|
+
_snapshot.addSerializeCallback(() => {
|
|
137
|
+
_pool?.fill(0);
|
|
138
|
+
_reset();
|
|
139
|
+
});
|
|
140
|
+
_snapshot.addDeserializeCallback(_reset);
|
|
141
|
+
}
|
|
142
|
+
|
|
96
143
|
/**
|
|
97
144
|
* @param {number} size
|
|
98
145
|
* @param {string} what
|
|
@@ -237,10 +284,6 @@ function uuidv5(name, namespace) {
|
|
|
237
284
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
238
285
|
}
|
|
239
286
|
|
|
240
|
-
let _v7LastMs = 0;
|
|
241
|
-
let _v7Counter = 0;
|
|
242
|
-
let _v7Head = "";
|
|
243
|
-
|
|
244
287
|
/**
|
|
245
288
|
* A UUID v7: it starts with a timestamp, so ids sort by creation time.
|
|
246
289
|
* That keeps database indexes compact, which makes them great primary
|
|
@@ -272,10 +315,6 @@ function uuidv7() {
|
|
|
272
315
|
);
|
|
273
316
|
}
|
|
274
317
|
|
|
275
|
-
let _ulidLastMs = -1;
|
|
276
|
-
let _ulidHead = "";
|
|
277
|
-
const _ulidRandom = new Uint8Array(16);
|
|
278
|
-
|
|
279
318
|
/**
|
|
280
319
|
* @param {number} ms
|
|
281
320
|
* @returns {string}
|
|
@@ -311,6 +350,7 @@ function ulid(time) {
|
|
|
311
350
|
for (let i = at; i < at + 16; i++) out += CROCKFORD[pool[i] & 31];
|
|
312
351
|
return out;
|
|
313
352
|
}
|
|
353
|
+
if (_poolPid !== _process.pid) _refill();
|
|
314
354
|
const now = Date.now();
|
|
315
355
|
if (now > _ulidLastMs) {
|
|
316
356
|
_ulidLastMs = now;
|
|
@@ -330,9 +370,6 @@ function ulid(time) {
|
|
|
330
370
|
return out;
|
|
331
371
|
}
|
|
332
372
|
|
|
333
|
-
let _oidProcess = "";
|
|
334
|
-
let _oidCounter = 0;
|
|
335
|
-
|
|
336
373
|
/**
|
|
337
374
|
* A MongoDB-style ObjectId: 24 hex characters that start with the time, in
|
|
338
375
|
* seconds, and increase within the process.
|
|
@@ -347,6 +384,7 @@ let _oidCounter = 0;
|
|
|
347
384
|
* @throws {RangeError} If the time is not a valid timestamp.
|
|
348
385
|
*/
|
|
349
386
|
function objectId(time) {
|
|
387
|
+
if (_poolPid !== _process.pid) _refill();
|
|
350
388
|
if (!_oidProcess) {
|
|
351
389
|
const at = _take(8);
|
|
352
390
|
const pool = /** @type {Buffer} */ (_pool);
|