UNPKG

got

Version:

Human-friendly and powerful HTTP request library for Node.js

523 lines (522 loc) 18.8 kB
import { ADDRCONFIG, ALL, V4MAPPED, getDefaultResultOrder, lookup as dnsLookup, promises as dnsPromises, } from 'node:dns'; import { isIP } from 'node:net'; import os from 'node:os'; import { promisify } from 'node:util'; const ttl = { ttl: true }; const noResultErrorCodes = new Set(['ENODATA', 'ENOTFOUND', 'ENOENT']); const maximumCacheTtl = Math.floor(2_147_483_647 / 1000); const isPromiseLike = (value) => typeof value?.then === 'function'; const now = () => Date.now(); const hasUnexpired = (cache, key) => { const expires = cache.get(key); if (expires === undefined) { return false; } if (expires <= now()) { cache.delete(key); return false; } return true; }; const deleteExpiredMapEntries = (cache, time = now()) => { for (const [key, expires] of cache) { if (expires <= time) { cache.delete(key); } } }; // eslint-disable-next-line no-bitwise const hasFlag = (value, flag) => value !== undefined && (value & flag) === flag; const getInterfaceInfo = () => { let has4 = false; let has6 = false; for (const networkInterface of Object.values(os.networkInterfaces())) { if (networkInterface === undefined) { continue; } for (const address of networkInterface) { if (address.internal) { continue; } if (address.family === 'IPv6') { has6 = true; } else { has4 = true; } if (has4 && has6) { return { has4, has6 }; } } } return { has4, has6 }; }; const createNoResultError = (hostname) => { const error = new Error(`DNS cache lookup ENOTFOUND ${hostname}`); error.code = 'ENOTFOUND'; error.hostname = hostname; return error; }; const normalizeLookupOptions = (options) => { if (typeof options === 'number') { return { family: options }; } return options ?? {}; }; const normalizeResolverRecord = (record, family, maxTtl) => { const entryTtl = Math.min(record.ttl, maxTtl); return { address: record.address, family, expires: now() + (entryTtl * 1000), }; }; const map4To6 = (entries) => entries.map(entry => { if (entry.family === 6) { return entry; } return { ...entry, address: `::ffff:${entry.address}`, family: 6, }; }); const familyFromOptions = (options) => { if (options.family === 4 || options.family === 6) { return options.family; } if (options.family === 'IPv4') { return 4; } if (options.family === 'IPv6') { return 6; } return undefined; }; const orderFromOptions = (options) => { if (options.order !== undefined) { return options.order; } if (options.verbatim !== undefined) { return options.verbatim ? 'verbatim' : 'ipv4first'; } return getDefaultResultOrder(); }; const familiesFromOptions = (options) => { const family = familyFromOptions(options); if (family === 6 && hasFlag(options.hints, V4MAPPED) && hasFlag(options.hints, ALL)) { return [6, 4]; } if (family !== undefined) { return [family]; } if (orderFromOptions(options) === 'ipv6first') { return [6, 4]; } return [4, 6]; }; const filterFamiliesByAddrConfig = (families, options) => { if (!hasFlag(options.hints, ADDRCONFIG)) { return families; } const interfaceInfo = getInterfaceInfo(); return families.filter(family => family === 6 ? interfaceInfo.has6 : interfaceInfo.has4); }; const shouldQueryMappedIpv4 = (entries, options) => familyFromOptions(options) === 6 && hasFlag(options.hints, V4MAPPED) && !hasFlag(options.hints, ALL) && entries.every(entry => entry.family !== 6); const cacheKey = (hostname, family) => `${hostname}:${family}`; const lookupOptionsKey = (hostname, options) => `${hostname}:${familyFromOptions(options) ?? 0}:${options.hints ?? 0}:${orderFromOptions(options)}`; const shouldIgnoreResolveError = (error) => error.code !== undefined && noResultErrorCodes.has(error.code); const toLookupResult = ({ address, family }) => ({ address, family, }); export default class DnsCache { lookup; #cache; #resolver; #dnsLookupAsync; #cacheKeys = new Set(); #pending = new Map(); #pendingFallback = new Map(); #lookupOptionsToFallback = new Map(); #lookupOptionsWithoutFallback = new Map(); #maxTtl; #fallbackDuration; #errorTtl; #minimumVersionByHostname = new Map(); #activeQueriesByHostname = new Map(); #minimumVersion = 0; #clearVersion = 0; constructor({ cache = new Map(), maxTtl = maximumCacheTtl, fallbackDuration = 3600, errorTtl = 0.15, resolver = new dnsPromises.Resolver(), lookup = dnsLookup, } = {}) { this.#cache = cache; this.#resolver = resolver; this.#maxTtl = Math.min(maxTtl, maximumCacheTtl); this.#fallbackDuration = fallbackDuration; this.#errorTtl = errorTtl; this.#dnsLookupAsync = lookup === false ? undefined : promisify(lookup); this.lookup = this.#lookup.bind(this); } async lookupAsync(hostname, options) { const normalizedOptions = normalizeLookupOptions(options); const literalFamily = isIP(hostname); if (literalFamily !== 0) { const entries = [{ address: hostname, family: literalFamily, }]; return normalizedOptions.all ? entries : entries[0]; } let entries = await this.#query(hostname, normalizedOptions); entries = this.#filterEntries(entries, normalizedOptions); if (entries.length === 0) { throw createNoResultError(hostname); } const lookupResults = entries.map(entry => toLookupResult(entry)); return normalizedOptions.all ? lookupResults : lookupResults[0]; } clear(hostname) { this.#clearVersion++; if (hostname === undefined) { this.#minimumVersion = this.#clearVersion; this.#minimumVersionByHostname.clear(); for (const key of this.#cacheKeys) { this.#cache.delete(key); } this.#cacheKeys.clear(); this.#pending.clear(); this.#pendingFallback.clear(); this.#lookupOptionsToFallback.clear(); this.#lookupOptionsWithoutFallback.clear(); return; } for (const family of [4, 6]) { const key = cacheKey(hostname, family); this.#cache.delete(key); this.#cacheKeys.delete(key); this.#pending.delete(key); } if (this.#activeQueriesByHostname.has(hostname)) { this.#minimumVersionByHostname.set(hostname, this.#clearVersion); } for (const key of this.#pendingFallback.keys()) { if (key.startsWith(`${hostname}:`)) { this.#pendingFallback.delete(key); } } for (const key of this.#lookupOptionsToFallback.keys()) { if (key.startsWith(`${hostname}:`)) { this.#lookupOptionsToFallback.delete(key); } } for (const key of this.#lookupOptionsWithoutFallback.keys()) { if (key.startsWith(`${hostname}:`)) { this.#lookupOptionsWithoutFallback.delete(key); } } } #lookup(hostname, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } if (callback === undefined) { throw new Error('Callback must be a function.'); } const normalizedOptions = normalizeLookupOptions(options); void this.#lookupAndCallback(hostname, normalizedOptions, callback); } async #lookupAndCallback(hostname, options, callback) { let result; try { result = await this.lookupAsync(hostname, options); } catch (error) { queueMicrotask(() => { const callbackWithError = callback; callbackWithError(error); }); return; } queueMicrotask(() => { if (options.all) { callback(null, result); return; } const entry = result; callback(null, entry.address, entry.family); }); } async #query(hostname, options) { this.#activeQueriesByHostname.set(hostname, (this.#activeQueriesByHostname.get(hostname) ?? 0) + 1); try { const lookupOptionKey = lookupOptionsKey(hostname, options); if (hasUnexpired(this.#lookupOptionsToFallback, lookupOptionKey)) { return await this.#fallbackLookupOnce(hostname, options, lookupOptionKey); } let families = filterFamiliesByAddrConfig(familiesFromOptions(options), options); const clearVersion = this.#clearVersion; let flattenedEntries = await this.#queryFamilies(hostname, families, clearVersion); if (shouldQueryMappedIpv4(flattenedEntries, options) && !families.includes(4)) { families = filterFamiliesByAddrConfig([4], options); flattenedEntries = await this.#queryFamilies(hostname, families, clearVersion); } if (flattenedEntries.length > 0 || this.#dnsLookupAsync === undefined) { return flattenedEntries; } if (hasUnexpired(this.#lookupOptionsWithoutFallback, lookupOptionKey)) { return []; } const fallbackEntries = await this.#fallbackLookupOnce(hostname, options, lookupOptionKey); if (!this.#isVersionCurrent(hostname, clearVersion)) { return fallbackEntries; } this.#deleteExpiredFallbackState(); if (fallbackEntries.length > 0 && this.#fallbackDuration > 0) { this.#lookupOptionsToFallback.set(lookupOptionKey, now() + (this.#fallbackDuration * 1000)); } else if (fallbackEntries.length === 0 && this.#errorTtl > 0) { this.#lookupOptionsWithoutFallback.set(lookupOptionKey, now() + (this.#errorTtl * 1000)); } return fallbackEntries; } finally { this.#finishQuery(hostname); } } #finishQuery(hostname) { const activeQueryCount = this.#activeQueriesByHostname.get(hostname); if (activeQueryCount === undefined) { return; } if (activeQueryCount > 1) { this.#activeQueriesByHostname.set(hostname, activeQueryCount - 1); return; } this.#activeQueriesByHostname.delete(hostname); this.#minimumVersionByHostname.delete(hostname); } #deleteExpiredFallbackState() { const time = now(); deleteExpiredMapEntries(this.#lookupOptionsToFallback, time); deleteExpiredMapEntries(this.#lookupOptionsWithoutFallback, time); } async #queryFamilies(hostname, families, clearVersion) { if (families.length === 1) { return this.#queryFamily(hostname, families[0], clearVersion); } const results = await Promise.allSettled(families.map(family => this.#queryFamily(hostname, family, clearVersion))); const entries = results.flatMap(result => result.status === 'fulfilled' ? result.value : []); if (entries.length > 0) { return entries; } const rejected = results.find(result => result.status === 'rejected'); if (rejected !== undefined) { if (rejected.reason instanceof Error) { throw rejected.reason; } throw new Error(String(rejected.reason)); } return []; } async #queryFamily(hostname, family, clearVersion) { const key = cacheKey(hostname, family); const cached = await this.#getCachedFamily(key, hostname); if (cached !== undefined) { return cached.entries; } if (!this.#isVersionCurrent(hostname, clearVersion)) { return this.#resolveAndCache(hostname, family, clearVersion); } const pending = this.#pending.get(key); if (pending !== undefined) { return pending; } const promise = this.#resolveAndCache(hostname, family, clearVersion); this.#pending.set(key, promise); try { return await promise; } finally { if (this.#pending.get(key) === promise) { this.#pending.delete(key); } } } async #getCachedFamily(key, hostname) { let cached = this.#cache.get(key); if (isPromiseLike(cached)) { cached = await cached; } if (cached === undefined) { return undefined; } if (!this.#isVersionCurrent(hostname, cached.clearVersion)) { return undefined; } if (cached.expires <= now()) { this.#cache.delete(key); this.#cacheKeys.delete(key); return undefined; } return cached; } async #resolveAndCache(hostname, family, clearVersion) { const entries = await this.#resolveFamily(hostname, family); const expires = this.#expiresFor(entries); const key = cacheKey(hostname, family); if (!this.#isVersionCurrent(hostname, clearVersion)) { return entries; } if (expires !== undefined) { await this.#setCachedFamily(key, hostname, { entries, expires, }, clearVersion); } else if (entries.length === 0 && this.#errorTtl > 0) { await this.#setCachedFamily(key, hostname, { entries, expires: now() + (this.#errorTtl * 1000), }, clearVersion); } return entries; } async #setCachedFamily(key, hostname, cached, clearVersion) { if (!this.#isVersionCurrent(hostname, clearVersion)) { return; } await this.#deleteExpiredCacheEntries(); await this.#cache.set(key, { ...cached, clearVersion, }); this.#cacheKeys.add(key); if (this.#isVersionCurrent(hostname, clearVersion)) { return; } let current = this.#cache.get(key); if (isPromiseLike(current)) { current = await current; } if (current?.clearVersion === clearVersion && !this.#isVersionCurrent(hostname, current.clearVersion)) { this.#cache.delete(key); this.#cacheKeys.delete(key); } } async #deleteExpiredCacheEntries() { const time = now(); await Promise.all([...this.#cacheKeys].map(async (key) => this.#deleteExpiredCacheEntry(key, time))); } async #deleteExpiredCacheEntry(key, time) { let cached = this.#cache.get(key); if (isPromiseLike(cached)) { cached = await cached; } if (cached === undefined) { this.#cacheKeys.delete(key); return; } if (cached.expires > time) { return; } let current = this.#cache.get(key); if (isPromiseLike(current)) { current = await current; } if (current?.clearVersion === cached.clearVersion && current.expires === cached.expires) { this.#cache.delete(key); this.#cacheKeys.delete(key); } } #isVersionCurrent(hostname, clearVersion) { const minimumVersion = this.#minimumVersionByHostname.get(hostname) ?? this.#minimumVersion; return clearVersion >= minimumVersion; } async #resolveFamily(hostname, family) { try { const records = family === 4 ? await this.#resolver.resolve4(hostname, ttl) : await this.#resolver.resolve6(hostname, ttl); return records.map(record => normalizeResolverRecord(record, family, this.#maxTtl)); } catch (error) { if (shouldIgnoreResolveError(error)) { return []; } throw error; } } #expiresFor(entries) { let expires = Number.POSITIVE_INFINITY; for (const entry of entries) { if (entry.expires === undefined) { continue; } expires = Math.min(expires, entry.expires); } return expires === Number.POSITIVE_INFINITY ? undefined : expires; } #filterEntries(entries, options) { if (hasFlag(options.hints, ADDRCONFIG)) { const interfaceInfo = getInterfaceInfo(); entries = entries.filter(entry => entry.family === 6 ? interfaceInfo.has6 : interfaceInfo.has4); } const family = familyFromOptions(options); if (family === 6) { const ipv6Entries = entries.filter(entry => entry.family === 6); if (hasFlag(options.hints, V4MAPPED)) { entries = hasFlag(options.hints, ALL) || ipv6Entries.length === 0 ? map4To6(entries) : ipv6Entries; } else { entries = ipv6Entries; } } else if (family === 4) { entries = entries.filter(entry => entry.family === 4); } return entries; } async #fallbackLookupOnce(hostname, options, key) { const pending = this.#pendingFallback.get(key); if (pending !== undefined) { return pending; } const promise = this.#fallbackLookup(hostname, options); this.#pendingFallback.set(key, promise); try { return await promise; } finally { if (this.#pendingFallback.get(key) === promise) { this.#pendingFallback.delete(key); } } } async #fallbackLookup(hostname, options) { if (this.#dnsLookupAsync === undefined) { return []; } let entries; try { entries = await this.#dnsLookupAsync(hostname, { ...options, all: true, }); } catch (error) { if (shouldIgnoreResolveError(error)) { return []; } throw error; } return entries.map(entry => ({ address: entry.address, family: entry.family, })); } }