kaven-utils
Version:
Utils for Node.js.
708 lines (707 loc) • 28 kB
JavaScript
/********************************************************************
* @author: Kaven
* @email: kaven@wuwenkai.com
* @website: http://blog.kaven.xyz
* @file: [Kaven-Utils] /src/KavenUtility.Net.ts
* @create: 2018-08-30 11:26:09.057
* @modify: 2025-05-24 08:01:53.803
* @version: 5.5.1
* @times: 238
* @lines: 811
* @copyright: Copyright © 2018-2025 Kaven. All Rights Reserved.
* @description: [description]
* @license: [license]
********************************************************************/
import pkgFollowRedirects from "follow-redirects";
import { ConvertTo, GetBaseDir, GetFileNameFromURL, GetStringBetween, HttpStatusCode, IsEqual, IsHttpsUrl, IsSuccessStatusCode, KavenCache, RemoveFromArray, Sleep, Strings_CR_LF, Strings_DoubleQuotes, Strings_Empty } from "kaven-basic";
import { createSocket } from "node:dgram";
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
import { isIP } from "node:net";
import { networkInterfaces } from "node:os";
import { KavenRequest } from "./KavenRequest.js";
import { MakeDirectory, MakeDirectorySync } from "./KavenUtility.FileSystem.js";
import { InternalLogger } from "./KavenUtility.Internal.js";
import { GetFileType } from "./KavenUtility.ThirdParty.js";
import { DefaultGetExternalIpByUPnPOptions } from "./base/Constants.js";
/**
* @since 5.4.0
* @version 2024-11-01
*/
export async function HttpGet(url, timeout) {
const request = new KavenRequest(url);
const r = await request.Execute(timeout);
return r;
}
/**
* Makes an asynchronous attempt to perform a GET request to one or more URLs.
* If multiple URLs are provided, it resolves with the first successful response.
* @param urls - A single URL or an array of URLs to attempt the GET request.
* @returns A promise that resolves with the first successful response or `undefined` if all attempts fail.
* @since 5.4.0
* @version 2024-05-24
*/
export async function HttpTryGetText(urls, timeout) {
try {
// If a single URL is provided, convert it to an array for uniform handling.
if (typeof urls === "string") {
urls = [urls];
}
let n = urls.length;
// If no URLs are provided, return undefined.
if (n === 0) {
return undefined;
}
// If only one URL is provided, directly call HttpGet and return its result.
else if (n === 1) {
const r = await HttpGet(urls[0], timeout);
if (r.IsSuccess) {
return r.Text;
}
return undefined;
}
// If multiple URLs are provided, attempt GET requests in parallel.
return new Promise((resolve) => {
for (const item of urls) {
// Perform a GET request for each URL.
HttpGet(item, timeout).then(result => {
// If a successful response is obtained, resolve the main promise.
if (result.IsSuccess && result.Text) {
resolve(result.Text);
}
else {
// If a response is not obtained, decrement the counter.
if (--n === 0) {
// If all attempts fail, resolve with undefined.
resolve(undefined);
}
}
}).catch(ex => {
// Log any internal errors but continue trying other URLs.
InternalLogger()?.Error(ex);
// 2024-05-23
// If a response is not obtained, decrement the counter.
if (--n === 0) {
// If all attempts fail, resolve with undefined.
resolve(undefined);
}
});
}
});
}
catch (ex) {
InternalLogger()?.Error(ex);
return undefined;
}
}
/**
* Makes an asynchronous attempt to perform a GET request to one or more URLs.
* If multiple URLs are provided, it resolves with the first successful response.
* @param urls - A single URL or an array of URLs to attempt the GET request.
* @returns A promise that resolves with the first successful response or `undefined` if all attempts fail.
* @since 5.4.0
* @version 2023-12-08
*/
export async function HttpTryGetJson(urls) {
try {
const r = await HttpTryGetText(urls);
if (!r) {
return undefined;
}
return JSON.parse(r);
}
catch (ex) {
InternalLogger()?.Error(ex);
return undefined;
}
}
/**
* @since 1.1.23
* @version 2025-05-22
*/
export async function HttpPost(url, data) {
const request = new KavenRequest(url);
request.RequestData = data;
return request.Execute();
}
/**
* @since 1.0.9
* @version 2025-05-24
*/
export async function GetExternalIp(options) {
options ??= {};
if (options.rules && options.rules.length > 0) {
for (const rule of options.rules) {
try {
const data = await HttpTryGetText(rule.url, 10000);
if (data) {
if (rule.rule) {
const ip = rule.rule(data);
if (isIP(ip)) {
return ip;
}
}
if (rule.regex) {
const re = new RegExp(rule.regex.pattern, rule.regex.flags);
const match = data.match(re);
if (match) {
if (rule.regex.resultIndex !== undefined) {
const ip = match[rule.regex.resultIndex];
if (isIP(ip)) {
return ip;
}
}
else {
for (const m of match) {
if (isIP(m)) {
return m;
}
}
}
}
}
if (isIP(data)) {
return data;
}
const ip = data.match(/\d+\.\d+\.\d+\.\d+/)?.[0];
if (ip && isIP(ip)) {
return ip;
}
}
InternalLogger()?.Warn(`GetExternalIp failed, url:${rule.url}, data:${data}`);
}
catch (ex) {
InternalLogger()?.Error(ex);
}
}
if (options.ignoreBuiltInRules) {
/* istanbul ignore next */
return Strings_Empty;
}
}
let ip = Strings_Empty;
const key = "GetExternalIp_Rules";
let rules = KavenCache.GetValue(key);
// Retrieve from server
if (!rules || rules.length === 0) {
rules = await HttpTryGetJson([
"https://kaven.xyz:3000/kaven/public.data.kaven.xyz/raw/branch/main/json/get-external-ip.json",
"https://public.data.kaven.xyz/json/get-external-ip.json",
]);
if (options.trace) {
InternalLogger()?.Info(`Retrieve ${rules?.length} rules from server`);
}
}
// Default fallback
if (!rules || rules.length === 0) {
rules = [
{
url: "https://ip.wuwenkai.com",
},
{
url: "https://ip.kaven.xyz",
},
];
}
const failedItems = [];
for (const item of rules) {
if (options.ipv6 !== undefined) {
if (options.ipv6) {
if (item.type !== "IPv6") {
continue;
}
}
else {
if (item.type === "IPv6") {
continue;
}
}
}
if (options.trace) {
InternalLogger()?.Info(`Try get external IP address from: ${item.url}`);
}
if (!item.url) {
continue;
}
const result = await GetExternalIp({
rules: [item],
ignoreBuiltInRules: true,
});
if (result) {
ip = result;
break;
}
failedItems.push(item);
}
if (failedItems.length > 0) {
RemoveFromArray(rules, failedItems);
rules.push(...failedItems);
}
if (ip) {
KavenCache.SetValue(key, rules);
}
else {
KavenCache.RemoveValue(key);
}
/* istanbul ignore next */
return ip; // 2021-03-06
}
/**
*
* @param url
* @param savePath
* @version 1.0.0
* @since 1.0.9
*/
export async function DownloadFile(url, savePath) {
await MakeDirectory(GetBaseDir(savePath));
const httpGet = pkgFollowRedirects.http.get;
const httpsGet = pkgFollowRedirects.https.get;
return new Promise((resolve, reject) => {
try {
const file = createWriteStream(savePath);
file.on("error", (err) => {
InternalLogger()?.Error(err);
});
const get = IsHttpsUrl(url) ? httpsGet : httpGet;
get(url, (response) => {
response.pipe(file);
file.on("finish", () => {
file.close();
return resolve(savePath);
});
})
.on("error", (err) => {
// Handle errors
if (existsSync(savePath)) {
unlinkSync(savePath);
return reject(err);
}
});
}
catch (ex) {
return reject(ex);
}
});
}
/**
*
* @param url
* @param options
* @param tryDetectFileTypeByMagicNumber
* @param minSizeInBytes
* @since 4.0.0
* @version 2022-04-09
* @returns
*/
export async function HttpDownload(url, options, tryDetectFileTypeByMagicNumber = true, minSizeInBytes) {
const { http, https } = await import("follow-redirects");
const httpGet = http.get;
const httpsGet = https.get;
const isHttps = IsHttpsUrl(url);
const get = isHttps ? httpsGet : httpGet;
const ContentDisposition = "content-disposition";
// const ContentLength = "content-length";
return new Promise((resolve, reject) => {
const req = get(url, res => {
const { statusCode, statusMessage, headers } = res;
if (statusCode !== HttpStatusCode.OK) {
return reject(new Error(`Request failed with ${statusMessage}[${statusCode}]`));
}
if (!options.Folder) {
return reject(new Error("Folder is empty."));
}
// if (minSizeInBytes) {
// const fileSize = Number(headers[ContentLength]);
// if (fileSize) {
// // InternalLogger()?.Info(`file size: ${fileSize}`.AsLog());
// if (fileSize < minSizeInBytes) {
// return (new Error(`File too small: ${fileSize} < ${minSizeInBytes}.`));
// } else {
// minSizeInBytes = undefined;
// }
// } else {
// InternalLogger()?.Warn(BuildLogString(LogLevel.Warn, "Cannot read file size from http headers."));
// }
// }
if (!options.Name) {
const contentDispositionHeader = ConvertTo(headers[ContentDisposition]);
if (contentDispositionHeader) {
// Content-Disposition: form-data
// Content-Disposition: form-data; name="fieldName"
// Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"
const parameters = contentDispositionHeader.split(";");
let temp = parameters.find((p) => p.startsWith("filename"));
if (temp) {
options.Name = GetStringBetween(temp, Strings_DoubleQuotes, Strings_DoubleQuotes, 1)[0];
}
if (!options.Name) {
temp = parameters.find((p) => p.startsWith("name"));
if (temp) {
options.Name = GetStringBetween(temp, Strings_DoubleQuotes, Strings_DoubleQuotes, 1)[0];
}
}
}
if (!options.Name) {
options.NameWithExtension = GetFileNameFromURL(url);
}
}
if (options.Folder) {
MakeDirectorySync(options.Folder);
}
if (tryDetectFileTypeByMagicNumber) {
// response.data.on("readable", () => {
// const chunk = response.data.read(16);
// if (chunk) {
// // InternalLogger()?.Info("got %d bytes of data", chunk.length);
// const fileType = KavenUtility.GetFileType(chunk);
// if (fileType && fileType.ext) {
// newFilePath = KavenUtility.ChangeFileExtension(options.FilePath, fileType.ext);
// }
// }
// });
let writeStream;
let buffer;
let lastTime = Date.now();
const timer = setInterval(() => {
if (Date.now() - lastTime > 5000) {
clearInterval(timer);
writeStream?.close();
reject(new Error("Timeout"));
}
}, 5000);
const Reject = (err) => {
clearInterval(timer);
if (writeStream !== undefined) {
writeStream.close();
}
reject(err);
};
const WriteData = (data) => {
lastTime = Date.now();
if (writeStream !== undefined) {
writeStream.write(data, err => {
if (err) {
return Reject(err);
}
lastTime = Date.now();
});
}
else {
GetFileType(ConvertTo(data)).then(fileType => {
if (fileType && fileType.ext) {
options.Extension = fileType.ext;
}
writeStream = createWriteStream(options.DynamicPath);
writeStream.on("close", () => {
if (minSizeInBytes) {
return Reject(new Error(`File too small: ${buffer ? buffer.length : 0} < ${minSizeInBytes}.`));
}
return resolve(options);
}).on("error", (err) => {
return Reject(err);
});
WriteData(data);
}).catch(err => {
return Reject(err);
});
}
};
res.on("data", (chunk) => {
if (chunk) {
// InternalLogger()?.Info("got %d bytes of data", chunk.length);
if (minSizeInBytes) {
if (buffer === undefined) {
buffer = Buffer.from(ConvertTo(chunk));
}
else {
buffer = Buffer.concat([buffer, Buffer.from(ConvertTo(chunk))]);
}
if (buffer.length > minSizeInBytes) {
WriteData(buffer);
minSizeInBytes = undefined;
}
}
else {
WriteData(chunk);
}
}
});
res.on("end", () => {
process.nextTick(() => {
if (writeStream !== undefined) {
writeStream.end();
}
else {
if (minSizeInBytes) {
return Reject(new Error(`File too small: ${buffer ? buffer.length : 0} < ${minSizeInBytes}.`));
}
return Reject(new Error());
}
});
});
res.on("error", (err) => {
return Reject(err);
});
}
else {
// pipe the result stream into a file on disc
res.pipe(createWriteStream(options.DynamicPath))
.on(/* "finish" */ "close", () => {
// InternalLogger()?.Info("finished");
return resolve(options);
}).on("error", (err) => {
return reject(err);
});
}
});
req.on("error", (e) => {
reject(e);
});
});
}
/**
* @since 5.0.4
* @version 2023-11-25
*/
export async function ExecuteSoapAction(options) {
const { url, serviceType, action, actionResultName, trace } = options;
const soapAction = `"${serviceType}#${action}"`;
const data = "<s:Envelope" +
" xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"" +
" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">" +
" <s:Body>" +
` <u:${action} xmlns:u="${serviceType}">` +
` </u:${action}>` +
" </s:Body>" +
"</s:Envelope>";
const request = new KavenRequest(url);
request.RequestData = data;
request.UpdateHeader("Content-Type", "text/xml; charset=\"utf-8\"");
request.UpdateHeader("SOAPAction", soapAction);
const r = await request.Execute();
if (!IsSuccessStatusCode(r.Status)) {
if (trace) {
InternalLogger()?.Warn(`[ExecuteSoapAction][${r.StatusText ?? r.Status}] ${url}, ${serviceType}, ${action}, ${r.Text}`);
}
return;
}
if (r.Text && actionResultName) {
const result = GetStringBetween(r.Text, `<${actionResultName}>`, `</${actionResultName}>`, 1)[0];
return result;
}
return r.Text;
}
/**
* @since 5.0.3
* @version 2023-12-08
*/
export async function GetExternalIpByUPnP(parameters) {
const options = {
...DefaultGetExternalIpByUPnPOptions,
...parameters,
};
const getFromLocation = async (location) => {
const r = await HttpGet(location);
if (!IsSuccessStatusCode(r.Status)) {
if (options.trace) {
InternalLogger()?.Warn(`[${r.StatusText ?? r.Status}] ${location}, ${r.Text}`);
}
return;
}
if (!r.Text) {
return;
}
const ip = GetStringBetween(r.Text, "<NewExternalIPAddress>", "</NewExternalIPAddress>", 1)[0];
return {
ip,
data: r.Text,
};
};
const cacheKey = "GetExternalIpByUPnP_Cache";
if (options.ignoreCache !== true) {
try {
const cache = KavenCache.GetValue(cacheKey);
if (cache !== undefined) {
if (typeof cache === "string") {
const r = await getFromLocation(cache);
if (options.checkIp(r?.ip)) {
return r?.ip;
}
}
else {
const ip = await ExecuteSoapAction(cache);
if (options.checkIp(ip)) {
return ip;
}
}
}
}
catch (ex) {
InternalLogger()?.Warn(ex);
KavenCache.RemoveValue(cacheKey);
}
}
// Start the timer
const startTime = process.hrtime();
/**
* SSDP M-SEARCH message
* HOST: Specifies the multicast address and port.
* MAN: Specifies the discovery method. In this case, it's set to "ssdp:discover."
* MX: Specifies the maximum wait time in seconds for responses.
* ST: Specifies the search target. "ssdp:all" means the device is searching for all types of devices.
*/
const searchMessage = [
"M-SEARCH * HTTP/1.1",
`HOST: ${options.multicastIp}:${options.port}`,
"MAN: \"ssdp:discover\"",
`MX: ${Math.max(1, Math.floor(options.timeoutMilliseconds / 1000))}`,
"ST: ssdp:all",
Strings_Empty,
].join(Strings_CR_LF) + Strings_CR_LF;
const socketList = [];
try {
const messageList = [];
const locationList = [];
// Get a list of network interfaces
const interfaces = networkInterfaces();
// Iterate over each network interface
Object.keys(interfaces).forEach((interfaceName) => {
const interfaceInfo = interfaces[interfaceName];
// Iterate over each address in the interface
interfaceInfo?.forEach((addressInfo) => {
// Check if it's an IPv4 address and not a loopback address
if (addressInfo.family === "IPv4" && !addressInfo.internal && addressInfo) {
// Create a UDP socket for each interface and bind to the address
const socket = createSocket("udp4");
socketList.push(socket);
socket.on("listening", () => {
socket.setBroadcast(true);
socket.setMulticastTTL(128);
socket.addMembership(options.multicastIp);
});
// Listen for responses (optional)
socket.on("message", (msg, rinfo) => {
const message = msg.toString();
if (options.trace) {
InternalLogger()?.Info(`Received response from ${rinfo.address}:${rinfo.port} on interface ${interfaceName}: `, message);
}
messageList.push(message);
});
// Handle socket errors
socket.on("error", (err) => {
InternalLogger()?.Error(`Socket error on interface ${interfaceName}:`, err);
});
if (options.trace) {
// Handle socket close
socket.on("close", () => {
InternalLogger()?.Info(`Socket closed on interface ${interfaceName}`);
});
}
socket.bind({
address: addressInfo.address,
port: options.localPort,
exclusive: false, // Allow multiple sockets to bind to the same address and port
}, () => {
// Send the SSDP M-SEARCH message
socket.send(searchMessage, options.port, options.multicastIp, (err, bytes) => {
if (err) {
InternalLogger()?.Error(`Error sending SSDP M-SEARCH on interface ${interfaceName}:`, err);
}
else if (options.trace) {
InternalLogger()?.Info(`Send SSDP M-SEARCH (${bytes}bytes) on interface ${interfaceName}`);
}
});
});
}
});
});
const getLocation = (message) => {
const lines = message.toString().split(Strings_CR_LF);
if (lines && lines.length > 0) {
for (const line of lines) {
const index = line.indexOf(":");
if (index >= 0) {
const name = line.substring(0, index);
if (IsEqual(name, "Location", true)) {
const value = line.substring(index + 1);
return value.trim();
}
}
}
}
return undefined;
};
while (true) {
try {
const message = messageList.pop();
if (message === undefined) {
// End the timer
const endTime = process.hrtime(startTime);
// Calculate the elapsed time in milliseconds
const elapsedTimeInMs = (endTime[0] * 1000 + endTime[1] / 1e6);
if (elapsedTimeInMs > options.timeoutMilliseconds) {
return;
}
else {
await Sleep(100);
}
continue;
}
const location = getLocation(message);
if (!location) {
InternalLogger()?.Warn("Location not found");
continue;
}
if (locationList.includes(location)) {
return;
}
InternalLogger()?.Info(`discover: ${location}`);
locationList.push(location);
const r = await getFromLocation(location);
if (!r) {
continue;
}
const { ip, data } = r;
if (options.checkIp(ip)) {
KavenCache.SetValue(cacheKey, location);
return ip;
}
const serviceList = GetStringBetween(data, "<service>", "</service>");
for (const service of serviceList) {
try {
const serviceType = GetStringBetween(service, "<serviceType>", "</serviceType>", 1)[0];
const controlURL = GetStringBetween(service, "<controlURL>", "</controlURL>", 1)[0];
if (serviceType && controlURL) {
const url = new URL(controlURL, location).toString();
if (options.trace) {
InternalLogger()?.Info(`url: ${url}, serviceType: ${serviceType}`);
}
const actionOptions = {
url,
serviceType,
action: "GetExternalIPAddress",
actionResultName: "NewExternalIPAddress",
trace: options.trace,
};
const ip = await ExecuteSoapAction(actionOptions);
if (options.checkIp(ip)) {
InternalLogger()?.Info(`Obtained IP successfully: ${url}, ${serviceType}`);
KavenCache.SetValue(cacheKey, actionOptions);
return ip;
}
}
}
catch (ex) {
InternalLogger()?.Warn(ex);
}
}
}
catch (ex) {
InternalLogger()?.Warn(ex);
}
}
}
finally {
socketList.forEach(p => p.close());
}
}