import type {Logger} from "@foxxmd/logging"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import address from "address"; import net from 'node:net'; import normalizeUrl from "normalize-url"; import { join as joinPath } from "path"; import { isDebugMode} from "../utils.ts"; import type {URLData} from "../../core/Atomic.ts"; import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket' import { WEBSOCKET_CLOSE_CODE_REASONS } from "../common/infrastructure/Atomic.ts"; import { loggerNoop } from "../common/MaybeLogger.ts"; import { formatBytes } from "./DataUtils.ts"; export interface PortReachableOpts { host: string, timeout?: number } /** * Copied from https://github.com/sindresorhus/is-port-reachable with error reporting * */ export const isPortReachable = async (port: number, opts: PortReachableOpts) => { const {host, timeout = 1000} = opts; const promise = new Promise(((resolve, reject) => { const socket = new net.Socket(); const onError = (e) => { socket.destroy(); reject(e); }; const onTimeout = () => { socket.destroy(); reject(new Error(`Connection timed out after ${timeout}ms`)); } socket.setTimeout(timeout); socket.once('error', onError); socket.once('timeout', onTimeout); socket.connect(port, host, () => { socket.end(); resolve(true); }); })); try { await promise; return true; } catch (e) { throw e; } } /** Test if a host:port is reachable via TCP * * Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket * * https://github.com/gr2m/node-net-interceptor/issues/2 * https://github.com/moll/node-mitm/issues/42 * */ export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => { const {host, timeout = 1000} = opts; const promise = new Promise(((resolve, reject) => { const client = net.connect({ timeout, port, host }, () => { client.end(); resolve(true); }); client.on('error', (err) => { client.destroy(); reject(err); }); client.on('timeout', () => { reject(new Error(`Connection timed out after ${timeout}ms`)); }); })); try { await promise; return true; } catch (e) { throw e; } } const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); const DOMAIN_AND_PORT: RegExp = new RegExp(/^([^:]+):(\d+)$/); const commonProtocols = ['http','https','ws','wss']; export const normalizeWebAddress = (val: string, options: {defaultPath?: string, removeTrailingSlash?: boolean} = {}): URLData => { let cleanUserUrl = val.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } const {defaultPath, removeTrailingSlash = true} = options; let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash}); if(normal === cleanUserUrl) { // checking to see if input was DOMAIN:PORT // in which case we also check DOMAIN isn't mistakenly a protocol // and if it isn't then we force a protocol based on port // so that we get a full URL out of this function const res = parseRegexSingle(DOMAIN_AND_PORT, cleanUserUrl); if(res !== undefined && !commonProtocols.includes(res.groups[0])) { const protocol = Number.parseInt(res.groups[1]) === 443 ? 'https:' : 'http:'; cleanUserUrl = `${protocol}//${cleanUserUrl}`; normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash}); } } const u = new URL(normal); let port: number; if (u.port === '') { port = u.protocol === 'https:' ? 443 : 80; } else { port = parseInt(u.port); // if user val does not include protocol and port is 443 then auto set to https if(port === 443 && !val.includes('http')) { if(u.protocol === 'http:') { u.protocol = 'https:'; } normal = normal.replace('http:', 'https:'); } } if(u.pathname === '/' && defaultPath !== undefined) { u.pathname = defaultPath; normal = normalizeUrl(u.toString()); } return { url: u, normal, port, input: val } } export const normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => { let cleanUserUrl = val.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) { cleanUserUrl = `ws://${cleanUserUrl}`; } const normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: false}) const url = new URL(normal); // default WS if (url.protocol === 'https:') { url.protocol = 'wss:'; } else if (url.protocol === 'http:') { url.protocol = 'ws:'; } else if(url.protocol === '') { url.protocol = 'ws:' } const {defaultPort, defaultPath} = options; let port: number; if(url.port === null || url.port === '') { if(defaultPort !== undefined) { url.port = defaultPort.toString(); port = parseInt(url.port); } else { port = url.protocol === 'ws:' ? 80 : 443; } } if(url.pathname === '/' && defaultPath !== undefined) { url.pathname = defaultPath; } return { url, normal: url.toString(), port, input: val } } export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => { // handle scenario where passed value is an empty string *before* handing to normalizeUrl // since this throws an error let trueUserUrl: string | undefined = undefined; if(userUrl !== undefined) { const trimmed = userUrl.trim(); if(trimmed !== '' && trimmed !== '""' && trimmed !== "''") { trueUserUrl = trimmed; } } const urlStr = trueUserUrl ?? `http://localhost:${defaultPort}`; let cleanUserUrl = urlStr.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, cleanUserUrl); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } const base = normalizeUrl(cleanUserUrl, {removeSingleSlash: true}); const u = new URL(base); if (u.port === '') { if (u.protocol === 'https:') { u.port = '443'; } else if (trueUserUrl.includes(`${u.hostname}:80`)) { u.port = '80'; } else { u.port = defaultPort.toString(); } } return u; } export const joinedUrl = (url: URL, ...paths: string[]): URL => { // https://github.com/jfromaniello/url-join#in-nodejs const finalUrl = new URL(url); finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); return finalUrl; } export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`); export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => { const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host; let v4: string, v6: string; try { v4 = address.ip(); v6 = address.ipv6(); } catch (e) { if (isDebugMode()) { if (logger !== undefined) { logger.warn(new Error('Could not get machine IP address', {cause: e})); } else { console.warn('Could not get machine IP address'); console.warn(e); } } } return { host: local, v4, v6 }; } const IPV4_REGEX = new RegExp(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/); export const isIPv4 = (address: string): boolean => { return parseRegexSingle(IPV4_REGEX, address) !== undefined; } export const formatWebsocketClose = (e: CloseEvent): string => { const closeParts = []; let code = `${e.code}`; const codeHint = WEBSOCKET_CLOSE_CODE_REASONS[e.code]; if(codeHint !== undefined) { code = `(${e.code}) ${codeHint}`; } closeParts.push(code); if(e.reason !== undefined) { closeParts.push(e.reason); } return closeParts.join(' => '); } export const isCloseEvent = (e: Event): e is CloseEvent => { return e.type === 'close'; } export const isErrorEvent = (e: Event): e is ErrorEvent => { return e.type === 'error'; } export const isRetryEvent = (e: Event): e is RetryEvent => { return e.type === 'retry'; } export const wsReadyStateToStr = (state: number): string => { switch(state) { case 0: return 'connecting'; case 1: return 'open'; case 2: return 'closing'; case 3: return 'closed'; default: return state.toString(); } } export type StreamBodyOpts = { logger?: Logger, chunkDefaultSize?: number, fileHint?: string, headers?: Headers } export const streamBodyProgress = async (stream: ReadableStream>, opts: StreamBodyOpts = {}) => { const { logger = loggerNoop, chunkDefaultSize = 1024 * 1024 * 10, // default to every 10MB, when we don't know response size fileHint = 'file', headers } = opts; let loading = true, chunks: any[] = []; const reader = stream.getReader(); let length: number, chunkReportSize: number = chunkDefaultSize, lastReportedSize: number = 0; if(headers !== undefined && null !== headers.get('content-length')) { length = +headers.get('content-length'); const [summary, size, unit] = formatBytes(length); if(unit === 'MiB' && size > 10) { switch(true) { case(size > 50): chunkReportSize = length/10; break; case(size > 20): chunkReportSize = length/5; break; default: chunkReportSize = length/3; break; } } logger.trace(`Downloading ${summary} ${fileHint}...`); } else { logger.trace(`Downloading ${fileHint} of unknown size (no content-length header)...`); } let received = 0; // Loop through the response stream and extract data chunks while (loading) { const { done, value } = await reader.read(); if (done) { // Finish loading loading = false; } else { // Push values to the chunk array chunks.push(value); received += value.length; lastReportedSize += value.length; if(lastReportedSize >= chunkReportSize) { logger.trace(`Downloaded ${formatBytes(received)[0]}...`); lastReportedSize = 0; } } } logger.trace(`Finished download!`); // Concat the chunks into a single array const body = new Uint8Array(received); let position = 0; // Order the chunks by their respective position for (const chunk of chunks) { body.set(chunk, position); position += chunk.length; } return body; } /** * Converts a rate limit expressed as "N requests per M seconds" into * an equivalent maximum requests-per-1-second value, as a float. * * @param maxRequests - Maximum number of requests allowed in the window * @param seconds - Length of the window, in seconds * @returns Maximum number of requests allowed per 1 second (float) */ export const maxRequestsPerSecond = (maxRequests: number, seconds: number): number => { if (!Number.isFinite(maxRequests) || maxRequests < 0) { throw new Error(`maxRequests must be a non-negative finite number, got ${maxRequests}`); } if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`seconds must be a positive finite number, got ${seconds}`); } return maxRequests / seconds; }