From 2a2aa61185300c0325ebbc681047c7eb0feea127 Mon Sep 17 00:00:00 2001 From: Vasco Santos Date: Tue, 13 Oct 2020 11:49:28 +0200 Subject: [PATCH] feat: cache --- .gitignore | 2 + package.json | 3 +- src/index.js | 65 +++++++++++++++++++++------ src/utils.js | 19 ++++++++ test/index.spec.js | 108 +++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 175 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 5d81abd..3fb2fca 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,5 @@ dist # TernJS port file .tern-port package-lock.json + +docs/ \ No newline at end of file diff --git a/package.json b/package.json index 1945c04..2b9e607 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "homepage": "https://github.com/vasco-santos/dns-over-http-resolver#readme", "dependencies": { "debug": "^4.2.0", - "native-fetch": "^2.0.1" + "native-fetch": "^2.0.1", + "timed-cache": "^1.1.4" }, "contributors": [ "Vasco Santos " diff --git a/src/index.js b/src/index.js index 3cc0cf8..9276e07 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,13 @@ const debug = require('debug') const log = debug('dns-over-http-resolver') log.error = debug('dns-over-http-resolver:error') -const { buildResource, fetch } = require('./utils') +const Cache = require('timed-cache') + +const { + buildResource, + fetch, + getCacheKey +} = require('./utils') /** * DNS over HTTP resolver. @@ -14,6 +20,7 @@ class Resolver { * @class */ constructor () { + this._cache = new Cache() this._servers = [ 'https://cloudflare-dns.com/dns-query', 'https://dns.google/resolve' @@ -66,22 +73,33 @@ class Resolver { * @returns {Promise>} */ async resolve4 (hostname) { + const recordType = 'A' + const cached = this._cache.get(getCacheKey(hostname, recordType)) + if (cached) { + return cached + } + for (const server of this._servers) { try { const response = await fetch(buildResource({ serverResolver: server, hostname, - recordType: 'A' + recordType })) const d = await response.json() - return d.Answer.map(a => a.data) + const data = d.Answer.map(a => a.data) + const ttl = Math.min(d.Answer.map(a => a.TTL)) + + this._cache.put(getCacheKey(hostname, recordType), data, { ttl }) + + return data } catch (err) { - log.error(`${server} could not resolve ${hostname} record A`) + log.error(`${server} could not resolve ${hostname} record ${recordType}`) } } - throw new Error(`Could not resolve ${hostname} record A`) + throw new Error(`Could not resolve ${hostname} record ${recordType}`) } /** @@ -91,22 +109,33 @@ class Resolver { * @returns {Promise>} */ async resolve6 (hostname) { + const recordType = 'AAAA' + const cached = this._cache.get(getCacheKey(hostname, recordType)) + if (cached) { + return cached + } + for (const server of this._servers) { try { const response = await fetch(buildResource({ serverResolver: server, hostname, - recordType: 'AAAA' + recordType })) const d = await response.json() - return d.Answer.map(a => a.data) + const data = d.Answer.map(a => a.data) + const ttl = Math.min(d.Answer.map(a => a.TTL)) + + this._cache.put(getCacheKey(hostname, recordType), data, { ttl }) + + return data } catch (err) { - log.error(`${server} could not resolve ${hostname} record AAAA`) + log.error(`${server} could not resolve ${hostname} record ${recordType}`) } } - throw new Error(`Could not resolve ${hostname} record AAAA`) + throw new Error(`Could not resolve ${hostname} record ${recordType}`) } /** @@ -116,23 +145,33 @@ class Resolver { * @returns {Promise>>} */ async resolveTxt (hostname) { + const recordType = 'TXT' + const cached = this._cache.get(getCacheKey(hostname, recordType)) + if (cached) { + return cached + } + for (const server of this._servers) { try { const response = await fetch(buildResource({ serverResolver: server, hostname, - recordType: 'TXT' + recordType })) const d = await response.json() + const data = d.Answer.map(a => [a.data.replace(/['"]+/g, '')]) + const ttl = Math.min(d.Answer.map(a => a.TTL)) + + this._cache.put(getCacheKey(hostname, recordType), data, { ttl }) - return d.Answer.map(a => [a.data.replace(/['"]+/g, '')]) + return data } catch (err) { - log.error(`${server} could not resolve ${hostname} record TXT`) + log.error(`${server} could not resolve ${hostname} record ${recordType}`) } } - throw new Error(`Could not resolve ${hostname} record TXT`) + throw new Error(`Could not resolve ${hostname} record ${recordType}`) } } diff --git a/src/utils.js b/src/utils.js index e13e07c..fd22cff 100644 --- a/src/utils.js +++ b/src/utils.js @@ -17,6 +17,12 @@ function buildResource ({ serverResolver, hostname, recordType }) { module.exports.buildResource = buildResource +/** + * Use fetch to find the record. + * + * @param {object} resource + * @returns {Promise} + */ function fetch (resource) { return nativeFetch(resource, { headers: new Headers({ @@ -26,3 +32,16 @@ function fetch (resource) { } module.exports.fetch = fetch + +/** + * Creates cache key composed by recordType and hostname. + * + * @param {string} hostname + * @param {string} recordType + * @returns {string} + */ +function getCacheKey (hostname, recordType) { + return `${recordType}_${hostname}` +} + +module.exports.getCacheKey = getCacheKey diff --git a/test/index.spec.js b/test/index.spec.js index 26064f8..63d6a96 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -8,6 +8,9 @@ const { isBrowser } = require('ipfs-utils/src/env') const DnsOverHttpResolver = require('../') +const getFetchPair = () => isBrowser ? [window, 'fetch'] : [nativeFetch, 'Promise'] +const getFetch = () => isBrowser ? window.fetch : nativeFetch.Promise + describe('dns-over-http-resolver', () => { let resolver @@ -16,6 +19,7 @@ describe('dns-over-http-resolver', () => { }) afterEach(() => { + resolver._cache.clear() sinon.restore() }) @@ -37,7 +41,7 @@ describe('dns-over-http-resolver', () => { const hostname = 'google.com' const recordType = 'A' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.resolve({ json: () => ({ Question: [{ name: 'google.com', type: 1 }], @@ -53,7 +57,7 @@ describe('dns-over-http-resolver', () => { it('resolves a dns record using IPv4', async () => { const hostname = 'google.com' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.resolve({ json: () => ({ Question: [{ name: 'google.com', type: 1 }], @@ -66,11 +70,33 @@ describe('dns-over-http-resolver', () => { expect(response).to.eql(['216.58.212.142']) }) + it('resolves a dns record using IPv4 and caches it for next resolve', async () => { + const hostname = 'google.com' + + const stub = sinon.stub(...getFetchPair()) + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [{ name: 'google.com', type: 1, TTL: 1000, data: '216.58.212.142' }] + }) + })) + + const response1 = await resolver.resolve4(hostname) + expect(response1).to.exist() + expect(getFetch().callCount).to.eql(1) + + const response2 = await resolver.resolve4(hostname) + expect(response2).to.exist() + expect(getFetch().callCount).to.eql(1) + + expect(response1).to.eql(response2) + }) + it('resolves a dns record of type AAAA', async () => { const hostname = 'google.com' const recordType = 'AAAA' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.resolve({ json: () => ({ Question: [{ name: 'google.com', type: 1 }], @@ -93,7 +119,7 @@ describe('dns-over-http-resolver', () => { it('resolves a dns record using IPv6', async () => { const hostname = 'google.com' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.resolve({ json: () => ({ Question: [{ name: 'google.com', type: 1 }], @@ -113,11 +139,40 @@ describe('dns-over-http-resolver', () => { expect(response).to.eql(['2a00:1450:4001:801::200e']) }) + it('resolves a dns record using IPv6 and caches it for next resolve', async () => { + const hostname = 'google.com' + + const stub = sinon.stub(...getFetchPair()) + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'google.com', type: 1 }], + Answer: [ + { + name: 'google.com', + type: 28, + TTL: 1000, + data: '2a00:1450:4001:801::200e' + } + ] + }) + })) + + const response1 = await resolver.resolve6(hostname) + expect(response1).to.exist() + expect(getFetch().callCount).to.eql(1) + + const response2 = await resolver.resolve6(hostname) + expect(response2).to.exist() + expect(getFetch().callCount).to.eql(1) + + expect(response1).to.eql(response2) + }) + it('resolves a dns record of type TXT', async () => { const hostname = 'google.com' const recordType = 'TXT' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.resolve({ json: () => ({ Question: [{ name: 'example.com', type: 1 }], @@ -147,7 +202,7 @@ describe('dns-over-http-resolver', () => { it('resolves a dns record using TXT', async () => { const hostname = 'example.com' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.resolve({ json: () => ({ Question: [{ name: 'example.com', type: 1 }], @@ -174,11 +229,48 @@ describe('dns-over-http-resolver', () => { expect(response).to.eql([['v=spf1 -all'], ['docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e']]) }) + it('resolves a dns record using TXT and caches it for next resolve', async () => { + const hostname = 'example.com' + + const stub = sinon.stub(...getFetchPair()) + stub.returns(Promise.resolve({ + json: () => ({ + Question: [{ name: 'example.com', type: 1 }], + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 86400, + data: '"v=spf1 -all"' + }, + { + name: 'example.com', + type: 16, + TTL: 86400, + data: '"docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"' + } + ] + }) + })) + + const response1 = await resolver.resolveTxt(hostname) + expect(response1).to.exist() + expect(response1).to.have.length(2) + expect(getFetch().callCount).to.eql(1) + + const response2 = await resolver.resolveTxt(hostname) + expect(response2).to.exist() + expect(response2).to.have.length(2) + expect(getFetch().callCount).to.eql(1) + + expect(response1).to.eql(response2) + }) + it('should fail if cannot resolve', async () => { const hostname = 'example.com' const recordType = 'TXT' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.returns(Promise.reject(new Error())) await expect(resolver.resolve(hostname, recordType)).to.eventually.be.rejected() @@ -187,7 +279,7 @@ describe('dns-over-http-resolver', () => { it('resolved a dns record from the second server if the first fails', async () => { const hostname = 'example.com' - const stub = isBrowser ? sinon.stub(window, 'fetch') : sinon.stub(nativeFetch, 'Promise') + const stub = sinon.stub(...getFetchPair()) stub.onCall(0).returns(Promise.reject(new Error())) stub.onCall(1).returns(Promise.resolve({ json: () => ({