UNPKG

@xan105/request

Version:

Simple HTTP request client with file download progress

211 lines (193 loc) 6.72 kB
/* Copyright (c) Anthony Beaumont This source code is licensed under the MIT License found in the LICENSE file in the root directory of this source tree. */ import http from "node:http"; import https from "node:https"; import { parse, URL } from "node:url"; import { lookup } from "node:dns"; import { isObj, isString, isStringNotEmpty, isArrayOfStringNotEmpty } from "@xan105/is"; import{ asStringNotEmpty, asIntegerPositiveOrZero } from "@xan105/is/opt"; import { Failure } from "@xan105/error"; import { UA, UAHint } from "../util/userAgent.js"; import { standardStatusMessage } from "../util/HTTPStatusCodes.js"; function request(href, payload, option = {}){ return new Promise((resolve, reject) => { //Multiple opt args if (isObj(payload)) { option = payload; payload = null; } const headers = { "User-Agent": UA, "Sec-GPC": 1 //Do not track }; const options = { method: asStringNotEmpty(option.method) ?? "GET", encoding: asStringNotEmpty(option.encoding) ?? "utf8", timeout: asIntegerPositiveOrZero(option.timeout) ?? 3000, maxRedirect: asIntegerPositiveOrZero(option.maxRedirect) ?? 0, maxRetry: asIntegerPositiveOrZero(option.maxRetry) ?? 0, retryDelay: asIntegerPositiveOrZero(option.retryDelay) ?? 200, headers: isObj(option.headers) ? Object.assign({}, headers, option.headers) : headers, signal: option.signal }; if (!isStringNotEmpty(href) && !isArrayOfStringNotEmpty(href)) return reject(new Failure("Expecting a non empty string for URL", "ERR_INVALID_ARGS")); if (isString(href)) href = [href]; const url = parse(href.at(-1)); if (!url.hostname || !url.protocol) return reject(new Failure("URL is malformed", "ERR_BAD_URL")); const lib = url.protocol === "https:" ? https : http; const req = lib.request(Object.assign({}, url, { method: options.method.toUpperCase(), headers: url.protocol === "https:" ? Object.assign({}, UAHint, options.headers) : options.headers, lookup: (hostname, opts, cb) => { opts.verbatim = true; //Do not prefer IPv4 over IPv6 (this will be the default in Node17) lookup(hostname, opts, cb) }, signal: options.signal }), (res) => { res.setEncoding(options.encoding); const details = { trace: href, address: res.socket.remoteFamily === "IPv6" ? "[" + res.socket.remoteAddress + "]" : res.socket.remoteAddress, domain: url.hostname, family: res.socket.remoteFamily, protocol: "http/" + res.httpVersion, security: res.socket.getProtocol?.() || null, port: res.socket.remotePort, sent: req._header, headers: res.headers }; if (req.method === "HEAD") { resolve({ code: res.statusCode, message: res.statusMessage, ...details }); } else if (res.statusCode >= 200 && res.statusCode < 300) { const data = []; res.on("data", (chunk) => { data.push(chunk); }).on("end", () => { if (res.complete) { resolve({ code: res.statusCode, message: res.statusMessage || standardStatusMessage(res.statusCode), ...details, body: data.join("") }); } else { option.maxRetry = options.maxRetry - 1; if (option.maxRetry < 0) { reject({ code: "ERR_INTERRUPTED", message: "The connection was terminated while the message was still being sent", ...details }); } else { setTimeout(function (){ return resolve(request(href, payload, option)); }, options.retryDelay); } } }).on("error", (err) => { option.maxRetry = options.maxRetry - 1; if (option.maxRetry < 0) { reject({ code: err.code, message: err.message, ...details }); req.destroy(); } else { setTimeout(function (){ return resolve(request(href, payload, option)); }, options.retryDelay); } }); } else if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { option.maxRedirect = options.maxRedirect - 1; if (option.maxRedirect < 0) { reject({ code: res.statusCode, message: res.statusMessage || standardStatusMessage(res.statusCode), ...details }); } else { const redirect = parse(res.headers.location).hostname ? res.headers.location : new URL(res.headers.location, `${url.protocol}//${url.hostname}`).href; href.push(redirect); if (req.method === "POST" && [301, 302, 303].includes(res.statusCode)){ option.method = "GET"; if (option.headers) { delete option.headers["content-length"]; delete option.headers["content-type"]; } if (payload) payload = null; } return resolve(request(href, payload, option)); } } else { option.maxRetry = options.maxRetry - 1; if (option.maxRetry < 0) { reject({ code: res.statusCode, message: res.statusMessage || standardStatusMessage(res.statusCode), ...details }); req.destroy(); } else { setTimeout(function (){ return resolve(request(href, payload, option)); }, options.retryDelay); } } }).setTimeout(options.timeout, () => { req.destroy(); }).on("error", (err) => { const aborted = options.signal instanceof AbortSignal && err.code === "ABORT_ERR"; option.maxRetry = options.maxRetry - 1; if (aborted || option.maxRetry < 0) { reject({ code: err.code, message: err.message, trace: href, domain: url.hostname, sent: req._header }); req.destroy(); } else { setTimeout(function () { return resolve(request(href, payload, option)); }, options.retryDelay); } }); if (req.method === "POST") { if (!payload) { reject(new Failure("Invalid payload", "ERR_INVALID_ARGS")); req.destroy(); } else { req.write(payload); } } req.end(); }); } export { request };