diff --git a/README.md b/README.md index e2ed1671cf4..7f28dc67dcd 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,61 @@ Returns: `URL` * **protocol** `string` (optional) * **search** `string` (optional) +## DNS caching + +Undici provides DNS caching via the `DNSResolver` class. + +This functionality is coming from [cacheable-lookup](https://github.com/szmarczak/cacheable-lookup/tree/9e60c9f6e74a003692aec68f3ddad93afe613b8f) package. + +By default this functionality is disabled. + +You can enable the default DNSResolver and pass it down by the agent to the pool by setting `dnsResolver` to `true`. + +```js +new Agent({ + dnsResolver: true +}) +``` + + +Here is an example of how to configure a custom DNSResolver: + +```js +new Agent({ + dnsResolver: true, + dnsResolverOptions: { + lookupOptions: { family: 6, hints: ALL } + } +}) +``` + +To disable DNS caching for an agent: + +```js +new Agent({ + dnsResolver: false +}) +``` + +Provide your custom DNS resolver, it must implement the method `lookup`: + +```js +new Agent({ + dnsResolver: new MyCustomDNSResolver() +}) +``` + +All arguments match [`cacheable-lookup`](https://github.com/szmarczak/cacheable-lookup/tree/9e60c9f6e74a003692aec68f3ddad93afe613b8f) package + +Extra arguments available in Undici: + +* **lookupOptions** + * **family** `4 | 6 | 0` - Default: `0` + * **hints** [`getaddrinfo flags`](https://nodejs.org/api/dns.html#supported-getaddrinfo-flags) + * **all** `Boolean` - Default: `false` + +* **scheduling** `'first' | 'random'` - Default: `'first'` + ## Specification Compliance This section documents parts of the HTTP/1.1 specification that Undici does @@ -400,9 +455,9 @@ Refs: https://fetch.spec.whatwg.org/#atomic-http-redirect-handling If you experience problem when connecting to a remote server that is resolved by your DNS servers to a IPv6 (AAAA record) first, there are chances that your local router or ISP might have problem connecting to IPv6 networks. In that case -undici will throw an error with code `UND_ERR_CONNECT_TIMEOUT`. +undici will throw an error with code `UND_ERR_CONNECT_TIMEOUT`. -If the target server resolves to both a IPv6 and IPv4 (A records) address and you are using a compatible Node version +If the target server resolves to both a IPv6 and IPv4 (A records) address and you are using a compatible Node version (18.3.0 and above), you can fix the problem by providing the `autoSelectFamily` option (support by both `undici.request` and `undici.Agent`) which will enable the family autoselection algorithm when establishing the connection. diff --git a/index.js b/index.js index bf46fc08d98..b4f998ba90b 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const errors = require('./lib/core/errors') const Pool = require('./lib/pool') const BalancedPool = require('./lib/balanced-pool') const Agent = require('./lib/agent') +const DNSResolver = require('./lib/dns-resolver') const util = require('./lib/core/util') const { InvalidArgumentError } = errors const api = require('./lib/api') @@ -150,6 +151,8 @@ module.exports.MockPool = MockPool module.exports.MockAgent = MockAgent module.exports.mockErrors = mockErrors +module.exports.DNSResolver = DNSResolver + const { EventSource } = require('./lib/eventsource/eventsource') module.exports.EventSource = EventSource diff --git a/lib/agent.js b/lib/agent.js index 9a07708654a..e79037ba740 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -7,6 +7,7 @@ const Pool = require('./pool') const Client = require('./client') const util = require('./core/util') const createRedirectInterceptor = require('./interceptor/redirectInterceptor') +const DNSResolver = require('./dns-resolver') const kOnConnect = Symbol('onConnect') const kOnDisconnect = Symbol('onDisconnect') @@ -22,6 +23,19 @@ function defaultFactory (origin, opts) { : new Pool(origin, opts) } +function getDNSResolver (options) { + // by default disable DNSResolver + if (options?.dnsResolver === false || options?.dnsResolver === undefined) { + return + } + + if (typeof options?.dnsResolver?.lookup === 'function') { + return options.dnsResolver + } + + return new DNSResolver(options.dnsResolverOptions) +} + class Agent extends DispatcherBase { constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { super() @@ -50,6 +64,7 @@ class Agent extends DispatcherBase { this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined + this[kOptions].dnsResolver = getDNSResolver(options) this[kMaxRedirections] = maxRedirections this[kFactory] = factory this[kClients] = new Map() diff --git a/lib/core/symbols.js b/lib/core/symbols.js index 68d8566fac0..df03ba0e161 100644 --- a/lib/core/symbols.js +++ b/lib/core/symbols.js @@ -59,5 +59,7 @@ module.exports = { kHTTP2CopyHeaders: Symbol('http2 copy headers'), kHTTPConnVersion: Symbol('http connection version'), kRetryHandlerDefaultRetry: Symbol('retry agent default retry'), - kConstruct: Symbol('constructable') + kConstruct: Symbol('constructable'), + kDnsCacheSize: Symbol('dns cache size'), + kDnsHostnamesToFallback: Symbol('dns hostnames to fallback') } diff --git a/lib/dns-resolver.js b/lib/dns-resolver.js new file mode 100644 index 00000000000..4ff2893c90b --- /dev/null +++ b/lib/dns-resolver.js @@ -0,0 +1,516 @@ +'use strict' + +// source: https://raw.githubusercontent.com/szmarczak/cacheable-lookup/9e60c9f6e74a003692aec68f3ddad93afe613b8f/source/index.mjs + +/** + +MIT License + +Copyright (c) 2019 Szymon Marczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +const { + V4MAPPED, + ADDRCONFIG, + ALL, + promises: dnsPromises, + lookup: dnsLookup +} = require('node:dns') +const { promisify } = require('node:util') +const os = require('node:os') +const { kDnsCacheSize, kDnsHostnamesToFallback } = require('./core/symbols') + +const { Resolver: AsyncResolver } = dnsPromises + +const kExpires = Symbol('expires') +const roundRobinStrategies = ['first', 'random'] + +const map4to6 = (entries) => { + for (const entry of entries) { + if (entry.family === 6) { + continue + } + + entry.address = `::ffff:${entry.address}` + entry.family = 6 + } +} + +const getIfaceInfo = () => { + let has4 = false + let has6 = false + + for (const device of Object.values(os.networkInterfaces())) { + for (const iface of device) { + if (iface.internal) { + continue + } + + if (iface.family === 'IPv6') { + has6 = true + } else { + has4 = true + } + + if (has4 && has6) { + return { has4, has6 } + } + } + } + + return { has4, has6 } +} + +const isIterable = (map) => { + return Symbol.iterator in map +} + +const ignoreNoResultErrors = (dnsPromise) => { + return dnsPromise.catch((error) => { + if ( + error.code === 'ENODATA' || + error.code === 'ENOTFOUND' || + error.code === 'ENOENT' // Windows: name exists, but not this record type + ) { + return [] + } + + throw error + }) +} + +const ttl = { ttl: true } +const all = { all: true } +const all4 = { all: true, family: 4 } +const all6 = { all: true, family: 6 } + +class DNSResolver { + #resolver + #cache + #dnsLookup + #iface + #pending = {} + #nextRemovalTime = false + #fallbackDuration + #removalTimeout + #hostnamesToFallback = new Set() + #resolve4 + #resolve6 + #scheduling + + constructor ({ + cache = new Map(), + maxTtl = Infinity, + fallbackDuration = 3600, + errorTtl = 0.15, + resolver = new AsyncResolver(), + lookup = dnsLookup, + lookupOptions, + scheduling = 'first' + } = {}) { + this.maxTtl = maxTtl + this.errorTtl = errorTtl + + if (roundRobinStrategies.includes(scheduling) === false) { + throw new Error(`roundRobinStrategy must be one of: ${roundRobinStrategies.join(', ')}`) + } + + this.#scheduling = scheduling + + this.#cache = cache + this.#resolver = resolver + this.#dnsLookup = lookup && promisify(lookup) + this.stats = { + cache: 0, + query: 0 + } + + if (this.#resolver instanceof AsyncResolver) { + this.#resolve4 = this.#resolver.resolve4.bind(this.#resolver) + this.#resolve6 = this.#resolver.resolve6.bind(this.#resolver) + } else { + this.#resolve4 = promisify(this.#resolver.resolve4.bind(this.#resolver)) + this.#resolve6 = promisify(this.#resolver.resolve6.bind(this.#resolver)) + } + + this.#iface = getIfaceInfo() + + this.#pending = {} + this.#nextRemovalTime = false + this.#hostnamesToFallback = new Set() + + this.#fallbackDuration = fallbackDuration + + if (fallbackDuration > 0) { + const interval = setInterval(() => { + this.#hostnamesToFallback.clear() + }, fallbackDuration * 1000) + + /* istanbul ignore next: There is no `interval.unref()` when running inside an Electron renderer */ + if (interval.unref) { + interval.unref() + } + } + + if (lookupOptions) { + this.lookup = (hostname, _options, callback) => this.#lookup(hostname, lookupOptions, callback) + } else { + this.lookup = this.#lookup.bind(this) + } + this.lookupAsync = this.lookupAsync.bind(this) + } + + get [kDnsCacheSize] () { + return this.#cache.size ?? 0 + } + + get [kDnsHostnamesToFallback] () { + return this.#hostnamesToFallback.size ?? 0 + } + + set servers (servers) { + this.clear() + + this.#resolver.setServers(servers) + } + + get servers () { + return this.#resolver.getServers() + } + + #lookup (hostname, options, callback) { + if (typeof options === 'function') { + callback = options + options = {} + } else if (typeof options === 'number') { + options = { + family: options + } + } + + if (!callback) { + throw new Error('Callback must be a function.') + } + + // eslint-disable-next-line promise/prefer-await-to-then + this.lookupAsync(hostname, options).then((result) => { + if (options.all) { + callback(null, result) + } else { + callback( + null, + result.address, + result.family, + result.expires, + result.ttl + ) + } + }, callback) + } + + async lookupAsync (hostname, options = {}) { + if (typeof options === 'number') { + options = { + family: options + } + } + + let cached = await this.query(hostname) + + if (options.family === 6) { + const filtered = [] + for (const entry of cached) { + if (entry.family === 6) { + filtered.push(entry) + } + } + if (options.hints & V4MAPPED) { + if ((options.hints & ALL) || filtered.length === 0) { + map4to6(cached) + } else { + cached = filtered + } + } else { + cached = filtered + } + } else if (options.family === 4) { + const filtered = [] + for (const entry of cached) { + if (entry.family === 4) { + filtered.push(entry) + } + } + cached = filtered + } + + if (options.hints & ADDRCONFIG) { + const filtered = [] + for (const entry of cached) { + if (entry.family === 6 && this.#iface.has6) { + filtered.push(entry) + } else if (entry.family === 4 && this.#iface.has4) { + filtered.push(entry) + } + } + cached = filtered + } + + if (cached.length === 0) { + const error = new Error(`DNSResolver ENOTFOUND ${hostname}`) + error.code = 'ENOTFOUND' + error.hostname = hostname + + throw error + } + + if (options.all) { + return cached + } + + if (this.#scheduling === 'first') { + return cached[0] + } else { + // random + return cached[Math.floor(Math.random() * cached.length)] + } + } + + async query (hostname) { + let cached = await this.#cache.get(hostname) + + if (cached) { + this.stats.cache++ + } + + if (!cached) { + const pending = this.#pending[hostname] + if (pending) { + this.stats.cache++ + cached = await pending + } else { + const newPromise = this.queryAndCache(hostname) + this.#pending[hostname] = newPromise + this.stats.query++ + try { + cached = await newPromise + } finally { + delete this.#pending[hostname] + } + } + } + + return cached + } + + async #resolve (hostname) { + // ANY is unsafe as it doesn't trigger new queries in the underlying server. + const entries = await Promise.allSettled([ + ignoreNoResultErrors(this.#resolve4(hostname, ttl)), + ignoreNoResultErrors(this.#resolve6(hostname, ttl)) + ]) + + if (entries[0].status === 'rejected' && entries[1].status === 'rejected') { + const error = new AggregateError([ + entries[0].reason, + entries[1].reason + ], `All resolvers failed for hostname: ${hostname}`) + throw error + } + + const A = entries[0].status === 'fulfilled' ? entries[0].value : [] + const AAAA = entries[1].status === 'fulfilled' ? entries[1].value : [] + + let aTtl = 0 + let aaaaTtl = 0 + let cacheTtl = 0 + + const now = Date.now() + + for (const entry of A) { + entry.family = 4 + entry.expires = now + entry.ttl * 1000 + + aTtl = Math.max(aTtl, entry.ttl) + } + + for (const entry of AAAA) { + entry.family = 6 + entry.expires = now + entry.ttl * 1000 + + aaaaTtl = Math.max(aaaaTtl, entry.ttl) + } + + if (A.length > 0) { + if (AAAA.length > 0) { + cacheTtl = Math.min(aTtl, aaaaTtl) + } else { + cacheTtl = aTtl + } + } else { + cacheTtl = aaaaTtl + } + + return { + entries: [...AAAA, ...A], + cacheTtl + } + } + + async #lookupViaDns (hostname) { + try { + const entries = await Promise.allSettled([ + // Passing {all: true} doesn't return all IPv4 and IPv6 entries. + // See https://github.com/szmarczak/cacheable-lookup/issues/42 + ignoreNoResultErrors(this.#dnsLookup(hostname, all4)), + ignoreNoResultErrors(this.#dnsLookup(hostname, all6)) + ]) + + if (entries[0].status === 'rejected' && entries[1].status === 'rejected') { + const error = new AggregateError([ + entries[0].reason, + entries[1].reason + ], `All resolvers failed for hostname: ${hostname}`) + throw error + } + + const A = entries[0].status === 'fulfilled' ? entries[0].value : [] + const AAAA = entries[1].status === 'fulfilled' ? entries[1].value : [] + + return { + entries: [...AAAA, ...A], + cacheTtl: 0 + } + } catch { + return { + entries: [], + cacheTtl: 0 + } + } + } + + async #set (hostname, data, cacheTtl) { + if (this.maxTtl > 0 && cacheTtl > 0) { + cacheTtl = Math.min(cacheTtl, this.maxTtl) * 1000 + data[kExpires] = Date.now() + cacheTtl + + try { + await this.#cache.set(hostname, data, cacheTtl) + } catch (error) { + this.lookupAsync = async () => { + const cacheError = new Error( + 'Cache Error. Please recreate the DNSResolver instance.' + ) + cacheError.cause = error + + throw cacheError + } + } + + if (isIterable(this.#cache)) { + this.#tick(cacheTtl) + } + } + } + + async queryAndCache (hostname) { + if (this.#hostnamesToFallback.has(hostname)) { + return this.#dnsLookup(hostname, all) + } + + let query = await this.#resolve(hostname) + + if (query.entries.length === 0 && this.#dnsLookup) { + query = await this.#lookupViaDns(hostname) + + if (query.entries.length !== 0 && this.#fallbackDuration > 0) { + // Use `dns.lookup(...)` for that particular hostname + this.#hostnamesToFallback.add(hostname) + } + } + + const cacheTtl = query.entries.length === 0 ? this.errorTtl : query.cacheTtl + await this.#set(hostname, query.entries, cacheTtl) + + return query.entries + } + + #tick (ms) { + const nextRemovalTime = this.#nextRemovalTime + + if (!nextRemovalTime || ms < nextRemovalTime) { + clearTimeout(this.#removalTimeout) + + this.#nextRemovalTime = ms + + this.#removalTimeout = setTimeout(() => { + this.#nextRemovalTime = false + + let nextExpiry = Infinity + + const now = Date.now() + + for (const [hostname, entries] of this.#cache) { + const expires = entries[kExpires] + + if (now >= expires) { + this.#cache.delete(hostname) + } else if (expires < nextExpiry) { + nextExpiry = expires + } + } + + if (nextExpiry !== Infinity) { + this.#tick(nextExpiry - now) + } + }, ms) + + /* istanbul ignore next: There is no `timeout.unref()` when running inside an Electron renderer */ + if (this.#removalTimeout.unref) { + this.#removalTimeout.unref() + } + } + } + + updateInterfaceInfo () { + const iface = this.#iface + + this.#iface = getIfaceInfo() + + if ( + (iface.has4 && !this.#iface.has4) || + (iface.has6 && !this.#iface.has6) + ) { + this.#cache.clear() + } + } + + clear (hostname) { + if (hostname) { + this.#cache.delete(hostname) + return + } + + this.#cache.clear() + } +} + +module.exports = DNSResolver diff --git a/lib/pool.js b/lib/pool.js index d74dcca5604..66e41561247 100644 --- a/lib/pool.js +++ b/lib/pool.js @@ -35,6 +35,7 @@ class Pool extends PoolBase { autoSelectFamily, autoSelectFamilyAttemptTimeout, allowH2, + dnsResolver, ...options } = {}) { super() @@ -57,6 +58,7 @@ class Pool extends PoolBase { maxCachedSessions, allowH2, socketPath, + lookup: dnsResolver?.lookup, timeout: connectTimeout, ...(util.nodeHasAutoSelectFamily && autoSelectFamily ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), ...connect diff --git a/test/dns-resolver.js b/test/dns-resolver.js new file mode 100644 index 00000000000..8c301c675ea --- /dev/null +++ b/test/dns-resolver.js @@ -0,0 +1,1036 @@ +'use strict' + +// source: https://raw.githubusercontent.com/szmarczak/cacheable-lookup/9e60c9f6e74a003692aec68f3ddad93afe613b8f/tests/test.mjs + +/** + +MIT License + +Copyright (c) 2019 Szymon Marczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +const { promises: dnsPromises, V4MAPPED, ADDRCONFIG, ALL } = require('node:dns') +const { promisify } = require('node:util') +const http = require('node:http') +const { test } = require('tap') +const originalDns = require('node:dns') +const proxyquire = require('proxyquire') +const { kDnsCacheSize, kDnsHostnamesToFallback } = require('../lib/core/symbols') +const osStub = {} +const dnsStub = { + ...originalDns +} + +const { Resolver: AsyncResolver } = dnsPromises + +const makeRequest = (options) => + new Promise((resolve, reject) => { + http.get(options, resolve).once('error', reject) + }) + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const mockedInterfaces = async (options) => { + const createInterfaces = (options = {}) => { + const interfaces = { + lo: [ + { + internal: true + } + ], + eth0: [] + } + + if (options.has4) { + interfaces.eth0.push({ + address: '192.168.0.111', + netmask: '255.255.255.0', + family: 'IPv4', + mac: '00:00:00:00:00:00', + internal: false, + cidr: '192.168.0.111/24' + }) + } + + if (options.has6) { + interfaces.eth0.push({ + address: 'fe80::c962:2946:a4e2:9f05', + netmask: 'ffff:ffff:ffff:ffff::', + family: 'IPv6', + mac: '00:00:00:00:00:00', + scopeid: 8, + internal: false, + cidr: 'fe80::c962:2946:a4e2:9f05/64' + }) + } + + return interfaces + } + + let interfaces = createInterfaces(options) + + const _updateInterfaces = (options = {}) => { + interfaces = createInterfaces(options) + } + + osStub.networkInterfaces = function () { + return interfaces + } + + DNSResolver._updateInterfaces = _updateInterfaces + return DNSResolver +} + +const createResolver = () => { + let counter = { + 4: 0, + 6: 0, + lookup: 0 + } + + const resolver = { + servers: ['127.0.0.1'], + getServers () { + return [...resolver.servers] + }, + setServers (servers) { + resolver.servers = [...servers] + }, + resolve: (hostname, options, callback) => { + let data + for (const server of resolver.servers) { + if (resolver.data[server][hostname]) { + data = resolver.data[server][hostname] + break + } + } + + if (hostname === 'econnrefused') { + const error = new Error(`ECONNREFUSED ${hostname}`) + error.code = 'ECONNREFUSED' + + callback(error) + return + } + + if (!data) { + const error = new Error(`ENOTFOUND ${hostname}`) + error.code = 'ENOTFOUND' + + callback(error) + return + } + + if (data.length === 0) { + const error = new Error(`ENODATA ${hostname}`) + error.code = 'ENODATA' + + callback(error) + return + } + + if (options.family === 4 || options.family === 6) { + data = data.filter((entry) => entry.family === options.family) + } + + callback(null, JSON.parse(JSON.stringify(data))) + }, + resolve4: (hostname, options, callback) => { + counter[4]++ + + return resolver.resolve(hostname, { ...options, family: 4 }, callback) + }, + resolve6: (hostname, options, callback) => { + counter[6]++ + + return resolver.resolve(hostname, { ...options, family: 6 }, callback) + }, + lookup: (hostname, options, callback) => { + // No need to implement hints yet + + counter.lookup++ + + if (!resolver.lookupData[hostname]) { + const error = new Error(`ENOTFOUND ${hostname}`) + error.code = 'ENOTFOUND' + error.hostname = hostname + + callback(error) + return + } + + let entries = resolver.lookupData[hostname] + + if (options.family === 4 || options.family === 6) { + entries = entries.filter((entry) => entry.family === options.family) + } + + if (options.all) { + callback(null, entries) + return + } + + callback(null, entries[0]) + }, + data: { + '127.0.0.1': { + agentdns: [ + { address: '127.0.0.1', family: 4, ttl: 60 } + ], + localhost: [ + { address: '127.0.0.1', family: 4, ttl: 60 }, + { address: '::ffff:127.0.0.2', family: 6, ttl: 60 } + ], + example: [{ address: '127.0.0.127', family: 4, ttl: 60 }], + temporary: [{ address: '127.0.0.1', family: 4, ttl: 1 }], + twoSeconds: [{ address: '127.0.0.1', family: 4, ttl: 2 }], + ttl: [{ address: '127.0.0.1', family: 4, ttl: 1 }], + maxTtl: [{ address: '127.0.0.1', family: 4, ttl: 60 }], + static4: [{ address: '127.0.0.1', family: 4, ttl: 1 }], + zeroTtl: [{ address: '127.0.0.127', family: 4, ttl: 0 }], + multiple: [ + { address: '127.0.0.127', family: 4, ttl: 0 }, + { address: '127.0.0.128', family: 4, ttl: 0 } + ], + outdated: [{ address: '127.0.0.1', family: 4, ttl: 1 }] + }, + '192.168.0.100': { + unique: [{ address: '127.0.0.1', family: 4, ttl: 60 }] + } + }, + lookupData: { + osHostname: [ + { address: '127.0.0.1', family: 4 }, + { address: '127.0.0.2', family: 4 } + ], + outdated: [{ address: '127.0.0.127', family: 4 }] + }, + get counter () { + return counter + }, + resetCounter () { + counter = { + 4: 0, + 6: 0, + lookup: 0 + } + } + } + + return resolver +} + +const resolver = createResolver() +dnsStub.lookup = resolver.lookup +const DNSResolver = proxyquire('../lib/dns-resolver', { + 'node:os': osStub, + 'node:dns': dnsStub +}) + +const verify = (t, entry, value) => { + if (Array.isArray(value)) { + // eslint-disable-next-line guard-for-in + for (const key in value) { + t.equal( + typeof entry[key].expires === 'number' && + entry[key].expires >= Date.now() - 1000, + true + ) + t.equal(typeof entry[key].ttl === 'number' && entry[key].ttl >= 0, true) + + if (!('ttl' in value[key]) && 'ttl' in entry[key]) { + value[key].ttl = entry[key].ttl + } + + if (!('expires' in value[key]) && 'expires' in entry[key]) { + value[key].expires = entry[key].expires + } + } + } else { + t.equal( + typeof entry.expires === 'number' && entry.expires >= Date.now() - 1000, + true + ) + t.equal(typeof entry.ttl === 'number' && entry.ttl >= 0, true) + + if (!('ttl' in value)) { + value.ttl = entry.ttl + } + + if (!('expires' in value)) { + value.expires = entry.expires + } + } + + t.same(entry, value) +} + +test('options.family', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // IPv4 + let entry = await cacheable.lookupAsync('localhost', { family: 4 }) + verify(t, entry, { + address: '127.0.0.1', + family: 4 + }) + + // IPv6 + entry = await cacheable.lookupAsync('localhost', { family: 6 }) + verify(t, entry, { + address: '::ffff:127.0.0.2', + family: 6 + }) +}) + +test('options.all', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + const entries = await cacheable.lookupAsync('localhost', { all: true }) + verify(t, entries, [ + { address: '::ffff:127.0.0.2', family: 6 }, + { address: '127.0.0.1', family: 4 } + ]) +}) + +test('options.all mixed with options.family', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // IPv4 + let entries = await cacheable.lookupAsync('localhost', { + all: true, + family: 4 + }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + + // IPv6 + entries = await cacheable.lookupAsync('localhost', { all: true, family: 6 }) + verify(t, entries, [{ address: '::ffff:127.0.0.2', family: 6 }]) +}) + +test('V4MAPPED hint', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // Make sure default behavior is right + await t.rejects(cacheable.lookupAsync('static4', { family: 6 }), { + code: 'ENOTFOUND' + }) + + // V4MAPPED + { + const entries = await cacheable.lookupAsync('static4', { + family: 6, + hints: V4MAPPED + }) + verify(t, entries, { address: '::ffff:127.0.0.1', family: 6 }) + } + + { + const entries = await cacheable.lookupAsync('localhost', { + family: 6, + hints: V4MAPPED + }) + verify(t, entries, { address: '::ffff:127.0.0.2', family: 6 }) + } +}) + +if (process.versions.node.split('.')[0] >= 14) { + test('ALL hint', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // ALL + const entries = await cacheable.lookupAsync('localhost', { + family: 6, + hints: V4MAPPED | ALL, + all: true + }) + + verify(t, entries, [ + { address: '::ffff:127.0.0.2', family: 6, ttl: 60 }, + { address: '::ffff:127.0.0.1', family: 6, ttl: 60 } + ]) + }) +} + +test('ADDRCONFIG hint', async (t) => { + // => has6 = false, family = 6 + { + const DNSResolver = await mockedInterfaces({ has4: true, has6: false }) + const cacheable = new DNSResolver({ resolver }) + + await t.rejects( + cacheable.lookupAsync('localhost', { family: 6, hints: ADDRCONFIG }), + { code: 'ENOTFOUND' } + ) + } + + // => has6 = true, family = 6 + { + const DNSResolver = await mockedInterfaces({ has4: true, has6: true }) + const cacheable = new DNSResolver({ resolver }) + + verify( + t, + await cacheable.lookupAsync('localhost', { + family: 6, + hints: ADDRCONFIG + }), + { + address: '::ffff:127.0.0.2', + family: 6 + } + ) + } + + // => has4 = false, family = 4 + { + const DNSResolver = await mockedInterfaces({ has4: false, has6: true }) + const cacheable = new DNSResolver({ resolver }) + + await t.rejects( + cacheable.lookupAsync('localhost', { family: 4, hints: ADDRCONFIG }), + { code: 'ENOTFOUND' } + ) + } + + // => has4 = true, family = 4 + { + const DNSResolver = await mockedInterfaces({ has4: true, has6: true }) + const cacheable = new DNSResolver({ resolver }) + + verify( + t, + await cacheable.lookupAsync('localhost', { + family: 4, + hints: ADDRCONFIG + }), + { + address: '127.0.0.1', + family: 4 + } + ) + } + + // Update interface info + { + const DNSResolver = await mockedInterfaces({ has4: false, has6: true }) + const cacheable = new DNSResolver({ resolver }) + + await t.rejects( + cacheable.lookupAsync('localhost', { family: 4, hints: ADDRCONFIG }), + { code: 'ENOTFOUND' } + ) + + // => has4 = true, family = 4 + DNSResolver._updateInterfaces({ has4: true, has6: true }) // Override os.networkInterfaces() + cacheable.updateInterfaceInfo() + + verify( + t, + await cacheable.lookupAsync('localhost', { + family: 4, + hints: ADDRCONFIG + }), + { + address: '127.0.0.1', + family: 4 + } + ) + } +}) + +test('caching works', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // Make sure default behavior is right + let entries = await cacheable.lookupAsync('temporary', { + all: true, + family: 4 + }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + + // Update DNS data + const resovlerEntry = resolver.data['127.0.0.1'].temporary[0] + const { address: resolverAddress } = resovlerEntry + resovlerEntry.address = '127.0.0.2' + + // Lookup again returns cached data + entries = await cacheable.lookupAsync('temporary', { all: true, family: 4 }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + + // Restore back + resovlerEntry.address = resolverAddress +}) + +test('respects ttl', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // Make sure default behavior is right + let entries = await cacheable.lookupAsync('ttl', { all: true, family: 4 }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + + // Update DNS data + const resolverEntry = resolver.data['127.0.0.1'].ttl[0] + const { address: resolverAddress } = resolverEntry + resolverEntry.address = '127.0.0.2' + + // Wait until it expires + await sleep(resolverEntry.ttl * 1000 + 1) + + // Lookup again + entries = await cacheable.lookupAsync('ttl', { all: true, family: 4 }) + verify(t, entries, [{ address: '127.0.0.2', family: 4 }]) + + // Restore back + resolverEntry.address = resolverAddress +}) + +test('throw when there are entries available but not for the requested family', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + await t.rejects(cacheable.lookupAsync('static4', { family: 6 }), { + code: 'ENOTFOUND' + }) +}) + +test('custom servers', async (t) => { + const cacheable = new DNSResolver({ resolver: createResolver() }) + + // .servers (get) + t.same(cacheable.servers, ['127.0.0.1']) + await t.rejects(cacheable.lookupAsync('unique'), { code: 'ENOTFOUND' }) + + // .servers (set) + cacheable.servers = ['127.0.0.1', '192.168.0.100'] + verify(t, await cacheable.lookupAsync('unique'), { + address: '127.0.0.1', + family: 4 + }) + + // Verify + t.same(cacheable.servers, ['127.0.0.1', '192.168.0.100']) +}) + +test('callback style', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // Custom promise for this particular test + const lookup = (...args) => + new Promise((resolve, reject) => { + cacheable.lookup(...args, (error, ...data) => { + if (error) { + reject(error) + } else { + resolve(data) + } + }) + }) + + // Without options + let result = await lookup('localhost') + t.equal(result.length, 4) + t.equal(result[0], '::ffff:127.0.0.2') + t.equal(result[1], 6) + t.equal(typeof result[2] === 'number' && result[2] >= Date.now() - 1000, true) + t.equal(typeof result[3] === 'number' && result[3] >= 0, true) + + // With options + result = await lookup('localhost', { family: 4, all: true }) + t.equal(result.length, 1) + verify(t, result[0], [{ address: '127.0.0.1', family: 4 }]) +}) + +test('works', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + verify(t, await cacheable.lookupAsync('localhost'), { + address: '::ffff:127.0.0.2', + family: 6 + }) +}) + +test('options.maxTtl', async (t) => { + // => maxTtl = 1 + { + const cacheable = new DNSResolver({ resolver, maxTtl: 1 }) + + // Make sure default behavior is right + verify(t, await cacheable.lookupAsync('maxTtl'), { + address: '127.0.0.1', + family: 4 + }) + + // Update DNS data + const resolverEntry = resolver.data['127.0.0.1'].maxTtl[0] + resolverEntry.address = '127.0.0.2' + + // Wait until it expires + await sleep(cacheable.maxTtl * 1000 + 10) + + // Lookup again + verify(t, await cacheable.lookupAsync('maxTtl'), { + address: '127.0.0.2', + family: 4 + }) + + // Reset + resolverEntry.address = '127.0.0.1' + } + + // => maxTtl = 0 + { + const cacheable = new DNSResolver({ resolver, maxTtl: 0 }) + + // Make sure default behavior is right + verify(t, await cacheable.lookupAsync('maxTtl'), { + address: '127.0.0.1', + family: 4 + }) + + // Update DNS data + const resolverEntry = resolver.data['127.0.0.1'].maxTtl[0] + resolverEntry.address = '127.0.0.2' + + // Wait until it expires + await sleep(cacheable.maxTtl * 1000 + 1) + + // Lookup again + verify(t, await cacheable.lookupAsync('maxTtl'), { + address: '127.0.0.2', + family: 4 + }) + + // Reset + resolverEntry.address = '127.0.0.1' + } +}) + +test('entry with 0 ttl', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + // Make sure default behavior is right + verify(t, await cacheable.lookupAsync('zeroTtl'), { + address: '127.0.0.127', + family: 4 + }) + + // Update DNS data + resolver.data['127.0.0.1'].zeroTtl[0].address = '127.0.0.1' + + // Lookup again + verify(t, await cacheable.lookupAsync('zeroTtl'), { + address: '127.0.0.1', + family: 4 + }) +}) + +test('http example', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + const options = { + hostname: 'example', + port: 9999, + lookup: cacheable.lookup + } + + await t.rejects(makeRequest(options), { + message: 'connect ECONNREFUSED 127.0.0.127:9999' + }) +}) + +test('.lookup() and .lookupAsync() are automatically bounded', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + await t.resolves(cacheable.lookupAsync('localhost')) + await t.resolves(promisify(cacheable.lookup)('localhost')) + + t.throws(() => cacheable.lookup('localhost'), { + message: 'Callback must be a function.' + }) +}) + +test('works (Internet connection)', async (t) => { + const cacheable = new DNSResolver() + + const { address, family } = await cacheable.lookupAsync( + '1dot1dot1dot1.cloudflare-dns.com', + { family: 4 } + ) + t.equal(address === '1.1.1.1' || address === '1.0.0.1', true) + t.equal(family, 4) +}) + +test('async resolver (Internet connection)', async (t) => { + const cacheable = new DNSResolver({ resolver: new AsyncResolver() }) + + const { address } = await cacheable.lookupAsync( + '1dot1dot1dot1.cloudflare-dns.com', + { family: 4 } + ) + t.equal(address === '1.1.1.1' || address === '1.0.0.1', true) +}) + +test('clear() works', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + await cacheable.lookupAsync('localhost') + t.equal(cacheable[kDnsCacheSize], 1) + + cacheable.clear() + + t.equal(cacheable[kDnsCacheSize], 0) +}) + +test('ttl works', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + await Promise.all([ + cacheable.lookupAsync('temporary'), + cacheable.lookupAsync('ttl') + ]) + t.equal(cacheable[kDnsCacheSize], 2) + + await sleep(2001) + + t.equal(cacheable[kDnsCacheSize], 0) +}) + +test('fallback works', async (t) => { + const cacheable = new DNSResolver({ resolver, fallbackDuration: 0.1 }) + resolver.resetCounter() + + const entries = await cacheable.lookupAsync('osHostname', { all: true }) + t.equal(entries.length, 2) + + t.equal(entries[0].address, '127.0.0.1') + t.equal(entries[0].family, 4) + + t.equal(entries[1].address, '127.0.0.2') + t.equal(entries[1].family, 4) + + t.equal(cacheable[kDnsCacheSize], 0) + + await cacheable.lookupAsync('osHostname', { all: true }) + + t.same(resolver.counter, { + 6: 1, + 4: 1, + lookup: 3 + }) + + await sleep(100) + + t.equal(cacheable[kDnsHostnamesToFallback], 0) +}) + +test('fallback works if ip change', async (t) => { + const cacheable = new DNSResolver({ resolver, fallbackDuration: 3600 }) + resolver.resetCounter() + resolver.lookupData.osHostnameChange = [ + { address: '127.0.0.1', family: 4 }, + { address: '127.0.0.2', family: 4 } + ] + + // First call: do not enter in `if (this._hostnamesToFallback.has(hostname)) {` + const entries = await cacheable.query('osHostnameChange', { all: true }) + t.equal(entries.length, 2) + + t.equal(entries[0].address, '127.0.0.1') + t.equal(entries[0].family, 4) + + t.equal(entries[1].address, '127.0.0.2') + t.equal(entries[1].family, 4) + + t.equal(cacheable[kDnsCacheSize], 0) + + // Second call: enter in `if (this._hostnamesToFallback.has(hostname)) {` + // And use _dnsLookup + // This call is used to ensure that this._pending is cleaned up when the promise is resolved + await cacheable.query('osHostnameChange', { all: true }) + + // Third call: enter in `if (this._hostnamesToFallback.has(hostname)) {` + // And use _dnsLookup + // Address should be different + resolver.lookupData.osHostnameChange = [ + { address: '127.0.0.3', family: 4 }, + { address: '127.0.0.4', family: 4 } + ] + const entries2 = await cacheable.query('osHostnameChange', { all: true }) + + t.equal(entries2.length, 2) + + t.equal(entries2[0].address, '127.0.0.3') + t.equal(entries2[0].family, 4) + + t.equal(entries2[1].address, '127.0.0.4') + t.equal(entries2[1].family, 4) + + t.equal(cacheable[kDnsCacheSize], 0) + + delete resolver.lookupData.osHostnameChange +}) + +test('real DNS queries first', async (t) => { + const resolver = createResolver({ delay: 0 }) + const cacheable = new DNSResolver({ + resolver, + fallbackDuration: 3600, + lookup: resolver.lookup + }) + + { + const entries = await cacheable.lookupAsync('outdated', { all: true }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + + { + const entries = await cacheable.lookupAsync('outdated', { all: true }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + } +}) + +test('fallback can be turned off', async (t) => { + const cacheable = new DNSResolver({ resolver, lookup: false }) + + await t.rejects(cacheable.lookupAsync('osHostname', { all: true }), { + message: 'DNSResolver ENOTFOUND osHostname' + }) +}) + +test('errors are cached', async (t) => { + const cacheable = new DNSResolver({ resolver, errorTtl: 0.1 }) + + await t.rejects(cacheable.lookupAsync('doesNotExist'), { + code: 'ENOTFOUND' + }) + + t.equal(cacheable[kDnsCacheSize], 1) + + await sleep(cacheable.errorTtl * 1000 + 10) + + t.equal(cacheable[kDnsCacheSize], 0) +}) + +test('passing family as options', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + const promisified = promisify(cacheable.lookup) + + const entry = await cacheable.lookupAsync('localhost', 6) + t.equal(entry.address, '::ffff:127.0.0.2') + t.equal(entry.family, 6) + + const address = await promisified('localhost', 6) + t.equal(address, '::ffff:127.0.0.2') +}) + +test('clear(hostname) works', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + await cacheable.lookupAsync('localhost') + await cacheable.lookupAsync('temporary') + + cacheable.clear('localhost') + + t.equal(cacheable[kDnsCacheSize], 1) +}) + +test('prevents overloading DNS', async (t) => { + const resolver = createResolver() + const { lookupAsync } = new DNSResolver({ + resolver, + lookup: resolver.lookup + }) + + await Promise.all([lookupAsync('localhost'), lookupAsync('localhost')]) + + t.same(resolver.counter, { + 4: 1, + 6: 1, + lookup: 0 + }) +}) + +test('returns IPv6 if no other entries available', async (t) => { + const DNSResolver = await mockedInterfaces({ has4: false, has6: true }) + const cacheable = new DNSResolver({ resolver }) + + verify(t, await cacheable.lookupAsync('localhost', { hints: ADDRCONFIG }), { + address: '::ffff:127.0.0.2', + family: 6 + }) + await mockedInterfaces({ has4: true, has6: true }) +}) + +test('throws when no internet connection', async (t) => { + const cacheable = new DNSResolver({ resolver }) + await t.rejects(cacheable.lookupAsync('econnrefused'), { + errors: [ + { code: 'ECONNREFUSED' }, + { code: 'ECONNREFUSED' } + ] + }) +}) + +test('throws when the cache instance is broken', async (t) => { + const cacheable = new DNSResolver({ + resolver, + cache: { + get: () => {}, + set: () => { + throw new Error('Something broke.') + } + } + }) + + await t.resolves(cacheable.lookupAsync('localhost')) + + await t.rejects(cacheable.lookupAsync('localhost'), { + message: 'Cache Error. Please recreate the DNSResolver instance.' + }) + + // not supported by this tap version + // t.equal(error.cause.message, 'Something broke.') +}) + +test('slow dns.lookup', async (t) => { + const cacheable = new DNSResolver({ + resolver, + lookup: (hostname, options, callback) => { + t.equal(hostname, 'osHostname') + t.equal(options.all, true) + t.equal(options.family === 4 || options.family === 6, true) + + setTimeout(() => { + if (options.family === 4) { + callback(null, [{ address: '127.0.0.1', family: 4 }]) + } + + if (options.family === 6) { + callback(null, [{ address: '::1', family: 6 }]) + } + }, 10) + } + }) + + const entry = await cacheable.lookupAsync('osHostname', 4) + + t.same(entry, { + address: '127.0.0.1', + family: 4 + }) +}) + +test('cache and query stats', async (t) => { + const cacheable = new DNSResolver({ resolver }) + + t.equal(cacheable.stats.query, 0) + t.equal(cacheable.stats.cache, 0) + + let entries = await cacheable.lookupAsync('temporary', { + all: true, + family: 4 + }) + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + + t.equal(cacheable.stats.query, 1) + t.equal(cacheable.stats.cache, 0) + + entries = await cacheable.lookupAsync('temporary', { all: true, family: 4 }) + + verify(t, entries, [{ address: '127.0.0.1', family: 4 }]) + + t.equal(cacheable.stats.query, 1) + t.equal(cacheable.stats.cache, 1) +}) + +test('agent: verify DNSResolver is working caching requests', t => { + t.plan(2) + const { Agent, request } = require('../index') + const dnsResolver = new DNSResolver({ resolver }) + dnsResolver.clear() + const agent = new Agent({ + dnsResolver + }) + t.equal(dnsResolver[kDnsCacheSize], 0) + + const server = http.createServer((req, res) => { + req.pipe(res) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://agentdns:${server.address().port}` + await request(origin, { dispatcher: agent }) + t.equal(dnsResolver[kDnsCacheSize], 1) + t.end() + }) +}) + +test('agent verify DNSResolver is disabled by default', t => { + t.plan(2) + const { Agent, request } = require('../index') + const dnsResolver = new DNSResolver({ resolver }) + dnsResolver.clear() + const agent = new Agent() + t.equal(dnsResolver[kDnsCacheSize], 0) + + const server = http.createServer((req, res) => { + req.pipe(res) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://localhost:${server.address().port}` + await request(origin, { dispatcher: agent }) + t.equal(dnsResolver[kDnsCacheSize], 0) + t.end() + }) +}) + +test('agent verify DNSResolver is disabled', t => { + t.plan(2) + const { Agent, request } = require('../index') + const dnsResolver = new DNSResolver({ resolver }) + dnsResolver.clear() + const agent = new Agent({ + dnsResolver: false + }) + t.equal(dnsResolver[kDnsCacheSize], 0) + + const server = http.createServer((req, res) => { + req.pipe(res) + }) + + t.teardown(server.close.bind(server)) + + server.listen(0, async () => { + const origin = `http://localhost:${server.address().port}` + await request(origin, { dispatcher: agent }) + t.equal(dnsResolver[kDnsCacheSize], 0) + t.end() + }) +}) diff --git a/test/types/agent.test-d.ts b/test/types/agent.test-d.ts index 5c12c480018..55d02e23b4b 100644 --- a/test/types/agent.test-d.ts +++ b/test/types/agent.test-d.ts @@ -1,12 +1,14 @@ import { Duplex, Readable, Writable } from 'stream' import { expectAssignable } from 'tsd' -import { Agent, Dispatcher } from '../..' +import { Agent, DNSResolver, Dispatcher } from '../..' import { URL } from 'url' expectAssignable(new Agent()) expectAssignable(new Agent({})) expectAssignable(new Agent({ maxRedirections: 1 })) expectAssignable(new Agent({ factory: () => new Dispatcher() })) +expectAssignable(new Agent({ dnsResolver: new DNSResolver() })) +expectAssignable(new Agent({ dnsResolverOptions: { cache: new Map() } })) { const agent = new Agent() diff --git a/test/types/dns-resolver.test-d.ts b/test/types/dns-resolver.test-d.ts new file mode 100644 index 00000000000..2de28d4ce1f --- /dev/null +++ b/test/types/dns-resolver.test-d.ts @@ -0,0 +1,74 @@ +import { expectAssignable, expectType } from 'tsd' +import { DNSResolver } from '../..' +import { LookupAddress } from 'dns' + +type LookupResponse = Promise + +// constructor +expectAssignable(new DNSResolver()) +expectAssignable(new DNSResolver({ + maxTtl: 0, + cache: new Map(), + fallbackDuration: 100, + errorTtl: 10, + scheduling: 'random', + lookupOptions: { + family: 4, + } +})) +expectAssignable(new DNSResolver({ + maxTtl: 0, + fallbackDuration: 100, + errorTtl: 10, +})) +expectAssignable(new DNSResolver({ + maxTtl: 0, + cache: new Map(), + fallbackDuration: 100, + errorTtl: 10, + scheduling: 'random', +})) + +{ + const dnsResolver = new DNSResolver() + + // lookup + expectAssignable(dnsResolver.lookup('localhost', (err, address, family, ttl, expires) => { + expectAssignable(err) + expectAssignable(address) + expectAssignable(family) + expectAssignable(ttl) + expectAssignable(expires) + })) + expectAssignable(dnsResolver.lookup('localhost', { family: 6 }, (err, address, family, ttl, expires) => { + expectAssignable(err) + expectAssignable(address) + expectAssignable(family) + expectAssignable(ttl) + expectAssignable(expires) + })) + expectAssignable(dnsResolver.lookup('localhost', 4, (err, address, family, ttl, expires) => { + expectAssignable(err) + expectAssignable(address) + expectAssignable(family) + expectAssignable(ttl) + expectAssignable(expires) + })) + expectAssignable(dnsResolver.lookup('localhost', { all: true }, (err, entries) => { + expectAssignable(err) + expectAssignable(entries) + })) + + // lookupAsync + expectAssignable(dnsResolver.lookupAsync('localhost', 4)) + expectAssignable>(dnsResolver.lookupAsync('localhost', { family: 6 })) + + // query and cache + expectAssignable(dnsResolver.query('localhost')) + expectAssignable(dnsResolver.queryAndCache('localhost')) + + // other utils + expectAssignable(dnsResolver.servers = ['0.0.0.0']) + expectAssignable(dnsResolver.updateInterfaceInfo()) + expectAssignable(dnsResolver.clear()) +} diff --git a/types/agent.d.ts b/types/agent.d.ts index 58081ce9372..6ded02dcc00 100644 --- a/types/agent.d.ts +++ b/types/agent.d.ts @@ -1,6 +1,7 @@ import { URL } from 'url' import Pool from './pool' import Dispatcher from "./dispatcher"; +import DNSResolver from './dns-resolver'; export default Agent @@ -22,6 +23,8 @@ declare namespace Agent { maxRedirections?: number; interceptors?: { Agent?: readonly Dispatcher.DispatchInterceptor[] } & Pool.Options["interceptors"] + + dnsResolverOptions?: DNSResolver.Options; } export interface DispatchOptions extends Dispatcher.DispatchOptions { diff --git a/types/dns-resolver.d.ts b/types/dns-resolver.d.ts new file mode 100644 index 00000000000..f6ff2e90461 --- /dev/null +++ b/types/dns-resolver.d.ts @@ -0,0 +1,69 @@ +import { lookup, promises, LookupAddress, LookupOneOptions, LookupAllOptions, LookupOptions } from 'node:dns' + + +export default DNSResolver + +declare class DNSResolver { + constructor(opts?: DNSResolver.Options) + lookup( + hostname: string, + family: number, + callback: (err: NodeJS.ErrnoException | null, address: string, family: number, ttl: number, expires: number) => void, + ): void; + lookup( + hostname: string, + options: LookupOneOptions, + callback: (err: NodeJS.ErrnoException | null, address: string, family: number, ttl: number, expires: number) => void, + ): void; + lookup( + hostname: string, + options: LookupAllOptions, + callback: (err: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void, + ): void; + lookup( + hostname: string, + options: LookupOptions, + callback: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family: number) => void, + ): void; + lookup( + hostname: string, + callback: (err: NodeJS.ErrnoException | null, address: string, family: number, ttl: number, expires: number) => void, + ): void; + servers: string[] + lookupAsync: (hostname: string, options: LookupOptions | number) => Promise + queryAndCache: (hostname: string) => Promise; + query: (hostname: string) => Promise; + updateInterfaceInfo: () => void; + clear: (hostname?: string) => void; +} + +interface CacheInterface { + set: (key:string, value: LookupAddress[]) => Promise; + get: (key: string) => Promise; + delete: (key?: string) => void; + clear: () => void; +} + +declare namespace DNSResolver { + export interface LookupAddress { + address: string; + family: 4 | 6; + ttl: number; + expires: number; + } + + export interface Options { + lookupOptions?: { + family?: 4 | 6 | 0; + hints?: number; + all?: boolean; + }; + maxTtl?: number; + cache?: Map | CacheInterface; + fallbackDuration?: number; + errorTtl?: number; + resolver?: typeof promises.Resolver; + lookup?: typeof lookup; + scheduling?: 'first' | 'random'; + } +} diff --git a/types/index.d.ts b/types/index.d.ts index 63e5c32bcef..cea98c1c892 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -16,6 +16,7 @@ import mockErrors from'./mock-errors' import ProxyAgent from'./proxy-agent' import RetryHandler from'./retry-handler' import { request, pipeline, stream, connect, upgrade } from './api' +import DNSResolver from './dns-resolver' export * from './util' export * from './cookies' @@ -30,7 +31,7 @@ export * from './content-type' export * from './cache' export { Interceptable } from './mock-interceptor' -export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler } +export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, DNSResolver } export default Undici declare namespace Undici { @@ -64,4 +65,5 @@ declare namespace Undici { var File: typeof import('./file').File; var FileReader: typeof import('./filereader').FileReader; var caches: typeof import('./cache').caches; + var DNSResolver: typeof import('./dns-resolver').default; } diff --git a/types/pool.d.ts b/types/pool.d.ts index bad5ba0308e..a8caa1543d2 100644 --- a/types/pool.d.ts +++ b/types/pool.d.ts @@ -2,6 +2,7 @@ import Client from './client' import TPoolStats from './pool-stats' import { URL } from 'url' import Dispatcher from "./dispatcher"; +import DNSResolver from "./dns-resolver"; export default Pool @@ -34,6 +35,8 @@ declare namespace Pool { /** The max number of clients to create. `null` if no limit. Default `null`. */ connections?: number | null; - interceptors?: { Pool?: readonly Dispatcher.DispatchInterceptor[] } & Client.Options["interceptors"] + interceptors?: { Pool?: readonly Dispatcher.DispatchInterceptor[] } & Client.Options["interceptors"]; + + dnsResolver?: DNSResolver; } }