fetch-dns
Version:
A drop-in replacement of Node's 'dns' module using 'fetch' and DNS-over-HTTPS
279 lines (242 loc) • 6.69 kB
text/typescript
/** @format */
import _ from "lodash";
import isFQDN from "validator/lib/isFQDN";
import { EventEmitter } from "events";
import { Record } from "./Record";
/* eslint-disable no-magic-numbers */
const CLEAN_PERIOD_MILLIS = 600 * 1000;
const PERIOD_BETWEEN_CLEANS = 1000; // Number of elements between cleans
const MAX_LISTENERS_FOR_COUNTER = 4096;
const MILLIS_PER_SECOND = 1000;
/* eslint-enable no-magic-numbers */
interface HasIsEmpty {
isEmpty(): boolean;
}
interface IsNotEmpty {
isEmpty(): false;
}
interface IsEmpty {
isEmpty(): true;
}
type MightBeEmpty =
| IsNotEmpty
| IsEmpty
| HasIsEmpty
| false
| undefined
| null;
function itIsEmpty(it: MightBeEmpty): it is IsEmpty {
if (!it || _.isNil(it)) return false;
return it.isEmpty();
}
function itIsNotEmpty(it: MightBeEmpty): it is IsNotEmpty {
if (!it || _.isNil(it)) return false;
return !it.isEmpty();
}
interface RecordEntry<T extends Record.recordtype.Any> {
record: Record.Typed<T>;
ttl: number;
}
const counter = new (class Counter extends EventEmitter {
private pit = Number.MIN_SAFE_INTEGER;
private horizon = Number.MIN_SAFE_INTEGER;
private lastCleaned: number = Date.now();
private shouldFire() {
return (
this.lastUsedValue() % PERIOD_BETWEEN_CLEANS === 0 ||
this.lastCleaned + CLEAN_PERIOD_MILLIS < Date.now()
);
}
lastUsedValue() {
return this.pit;
}
private runClean() {
_.defer(
this.emit.bind(this),
"clean",
Math.max(this.lastUsedValue() - PERIOD_BETWEEN_CLEANS, this.horizon),
);
}
nextValue() {
if (this.lastUsedValue() === Number.MAX_SAFE_INTEGER) {
// Congrats, you cycled over -- we're resetting everything.
this.horizon = Number.MAX_SAFE_INTEGER;
this.runClean();
this.horizon = this.pit = Number.MIN_SAFE_INTEGER;
} else if (this.shouldFire()) {
this.runClean();
this.horizon = this.lastUsedValue();
this.lastCleaned = Date.now();
}
return this.pit++;
}
})();
counter.setMaxListeners(MAX_LISTENERS_FOR_COUNTER);
function reverseHostname(hostname: string): string[] {
if (!isFQDN(hostname)) {
throw new Error(
`Hostname to cache is not a fully qualified domain name (FQDN): ${JSON.stringify(
hostname,
)} (${typeof hostname})`,
);
}
return _.reject(_.reverse(hostname.split(".")), _.isEmpty);
}
/* eslint-disable no-use-before-define */
interface Subdomains {
[key: string]: ResultCache;
}
/* eslint-enable no-use-before-define */
class RecordCache<T extends Record.recordtype.Any> implements HasIsEmpty {
private readonly _records: RecordEntry<T>[];
private readonly _setAt = Date.now();
private readonly _setPit = counter.nextValue();
constructor(records: RecordEntry<T>[]) {
this._records = _.cloneDeep(records);
this._listenForClean();
}
_listenForClean(): void {
if (_.isEmpty(this._records)) return;
counter.once("clean", this.clean.bind(this));
}
get records(): RecordEntry<T>[] {
const now = Date.now();
return _.cloneDeep(
_.filter(this._records, (record) => {
if (_.isEmpty(record) || _.isEmpty(record.record)) return false;
const ttlSeconds = record.ttl;
return ttlSeconds * MILLIS_PER_SECOND + this._setAt >= now;
}),
);
}
private clean(horizon: number = Number.MIN_SAFE_INTEGER): void {
const { _setPit, _records } = this;
if (_.isEmpty(_records)) return;
if (_setPit <= horizon) {
_records.length = 0;
} else {
this._listenForClean();
}
}
isEmpty() {
this.clean();
return _.isEmpty(this._records);
}
}
type RecordMapping = {
[T in Record.recordtype.Any]: RecordCache<T>;
};
class ResultCache implements HasIsEmpty {
private readonly _mapping = {} as RecordMapping;
private readonly _subdomains = {} as Subdomains;
constructor() {
this._listenForClean();
}
_listenForClean(): void {
counter.once("clean", this.clean.bind(this));
}
isEmpty(): boolean {
this.clean();
return (
_.every(this._mapping, itIsEmpty) && _.every(this._subdomains, itIsEmpty)
);
}
private clean(): void {
_.forOwn(this._subdomains, (results, sub) => {
if (itIsEmpty(results)) {
_.unset(this._subdomains, sub);
}
});
_.forOwn(this._mapping, (records, rrtype) => {
if (itIsEmpty(records)) {
_.unset(this._mapping, rrtype);
}
});
this._listenForClean();
}
getRecordCache<T extends Record.recordtype.Any>(
rrtype: T,
): RecordCache<T> | false {
const cache = this._mapping[rrtype];
if (itIsNotEmpty(cache)) {
return cache as RecordCache<T>;
} else {
_.unset(this._mapping, rrtype);
return false;
}
}
getSubdomain(sub: string): ResultCache {
const oldSub = this._subdomains[sub];
if (!oldSub || _.isEmpty(oldSub)) {
return (this._subdomains[sub] = new ResultCache());
} else {
return oldSub;
}
}
private setRecordCache<T extends Record.recordtype.Any>(
rrtype: T,
records: RecordEntry<T>[],
): void {
if (_.isEmpty(_.compact(records))) {
_.unset(this._mapping, rrtype);
} else {
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
(this._mapping as any)[rrtype] = new RecordCache(records);
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
/* eslint-enable @typescript-eslint/no-explicit-any */
}
}
setResult<T extends Record.recordtype.Any>(
domainParts: string[],
rrtype: T,
records: RecordEntry<T>[],
): void {
if (_.isEmpty(domainParts)) {
this.setRecordCache(rrtype, records);
} else {
const [head, ...rest] = domainParts;
this.getSubdomain(head).setResult(rest, rrtype, records);
}
}
getResult<T extends Record.recordtype.Any>(
domainParts: string[],
rrtype: T,
): Record.Typed<T>[] | false {
if (_.isEmpty(domainParts)) {
const result = this.getRecordCache(rrtype);
if (result !== false && itIsNotEmpty(result)) {
return _.map(result.records, "record");
} else {
return false;
}
} else {
const [head, ...rest] = domainParts;
return this.getSubdomain(head).getResult(rest, rrtype);
}
}
}
export default new (class DnsCache {
private readonly root = new ResultCache();
put<T extends Record.recordtype.Any>(
hostname: string,
rrtype: T,
records: RecordEntry<T>[],
): void {
records = _.compact(records);
if (_.isEmpty(hostname) || _.isEmpty(records)) return;
this.root.setResult(reverseHostname(hostname), rrtype, records);
}
check<T extends Record.recordtype.Any>(
hostname: string,
rrtype: T,
): false | Record.Typed<T>[] {
if (_.isEmpty(hostname)) return false;
const result = this.root.getResult(reverseHostname(hostname), rrtype);
if (result && !_.isEmpty(result)) {
return result;
} else {
return false;
}
}
})();