Skip to content

Commit 83db0fc

Browse files
SuperOleg39o.drapeza
andauthored
feat: DNS interceptor storage (#4589)
Co-authored-by: o.drapeza <[email protected]>
1 parent 96fb72c commit 83db0fc

File tree

5 files changed

+298
-16
lines changed

5 files changed

+298
-16
lines changed

docs/docs/api/Dispatcher.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,7 @@ The `dns` interceptor enables you to cache DNS lookups for a given duration, per
10431043
- The function should return a single record from the records array.
10441044
- By default a simplified version of Round Robin is used.
10451045
- The `records` property can be mutated to store the state of the balancing algorithm.
1046+
- `storage: DNSStorage` - Custom storage for resolved DNS records
10461047

10471048
> The `Dispatcher#options` also gets extended with the options `dns.affinity`, `dns.dualStack`, `dns.lookup` and `dns.pick` which can be used to configure the interceptor at a request-per-request basis.
10481049
@@ -1057,6 +1058,15 @@ It represents a map of DNS IP addresses records for a single origin.
10571058
- `4.ips` - (`DNSInterceptorRecord[] | null`) The IPv4 addresses.
10581059
- `6.ips` - (`DNSInterceptorRecord[] | null`) The IPv6 addresses.
10591060

1061+
**DNSStorage**
1062+
It represents a storage object for resolved DNS records.
1063+
- `size` - (`number`) current size of the storage.
1064+
- `get` - (`(origin: string) => DNSInterceptorOriginRecords | null`) method to get the records for a given origin.
1065+
- `set` - (`(origin: string, records: DNSInterceptorOriginRecords | null, options: { ttl: number }) => void`) method to set the records for a given origin.
1066+
- `delete` - (`(origin: string) => void`) method to delete records for a given origin.
1067+
- `full` - (`() => boolean`) method to check if the storage is full, if returns `true`, DNS lookup will be skipped in this interceptor and new records will not be stored.
1068+
```ts
1069+
10601070
**Example - Basic DNS Interceptor**
10611071

10621072
```js
@@ -1073,6 +1083,45 @@ const response = await client.request({
10731083
})
10741084
```
10751085

1086+
**Example - DNS Interceptor and LRU cache as a storage**
1087+
1088+
```js
1089+
const { Client, interceptors } = require("undici");
1090+
const QuickLRU = require("quick-lru");
1091+
const { dns } = interceptors;
1092+
1093+
const lru = new QuickLRU({ maxSize: 100 });
1094+
1095+
const lruAdapter = {
1096+
get size() {
1097+
return lru.size;
1098+
},
1099+
get(origin) {
1100+
return lru.get(origin);
1101+
},
1102+
set(origin, records, { ttl }) {
1103+
lru.set(origin, records, { maxAge: ttl });
1104+
},
1105+
delete(origin) {
1106+
lru.delete(origin);
1107+
},
1108+
full() {
1109+
// For LRU cache, we can always store new records,
1110+
// old records will be evicted automatically
1111+
return false;
1112+
}
1113+
}
1114+
1115+
const client = new Agent().compose([
1116+
dns({ storage: lruAdapter })
1117+
])
1118+
1119+
const response = await client.request({
1120+
origin: `http://localhost:3030`,
1121+
...requestOpts
1122+
})
1123+
```
1124+
10761125
##### `responseError`
10771126

10781127
The `responseError` interceptor throws an error for responses with status code errors (>= 400).

lib/interceptor/dns.js

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,44 @@ const DecoratorHandler = require('../handler/decorator-handler')
55
const { InvalidArgumentError, InformationalError } = require('../core/errors')
66
const maxInt = Math.pow(2, 31) - 1
77

8+
class DNSStorage {
9+
#maxItems = 0
10+
#records = new Map()
11+
12+
constructor (opts) {
13+
this.#maxItems = opts.maxItems
14+
}
15+
16+
get size () {
17+
return this.#records.size
18+
}
19+
20+
get (hostname) {
21+
return this.#records.get(hostname) ?? null
22+
}
23+
24+
set (hostname, records) {
25+
this.#records.set(hostname, records)
26+
}
27+
28+
delete (hostname) {
29+
this.#records.delete(hostname)
30+
}
31+
32+
// Delegate to storage decide can we do more lookups or not
33+
full () {
34+
return this.size >= this.#maxItems
35+
}
36+
}
37+
838
class DNSInstance {
939
#maxTTL = 0
1040
#maxItems = 0
11-
#records = new Map()
1241
dualStack = true
1342
affinity = null
1443
lookup = null
1544
pick = null
45+
storage = null
1646

1747
constructor (opts) {
1848
this.#maxTTL = opts.maxTTL
@@ -21,17 +51,14 @@ class DNSInstance {
2151
this.affinity = opts.affinity
2252
this.lookup = opts.lookup ?? this.#defaultLookup
2353
this.pick = opts.pick ?? this.#defaultPick
24-
}
25-
26-
get full () {
27-
return this.#records.size === this.#maxItems
54+
this.storage = opts.storage ?? new DNSStorage(opts)
2855
}
2956

3057
runLookup (origin, opts, cb) {
31-
const ips = this.#records.get(origin.hostname)
58+
const ips = this.storage.get(origin.hostname)
3259

3360
// If full, we just return the origin
34-
if (ips == null && this.full) {
61+
if (ips == null && this.storage.full()) {
3562
cb(null, origin)
3663
return
3764
}
@@ -55,7 +82,7 @@ class DNSInstance {
5582
}
5683

5784
this.setRecords(origin, addresses)
58-
const records = this.#records.get(origin.hostname)
85+
const records = this.storage.get(origin.hostname)
5986

6087
const ip = this.pick(
6188
origin,
@@ -89,7 +116,7 @@ class DNSInstance {
89116

90117
// If no IPs we lookup - deleting old records
91118
if (ip == null) {
92-
this.#records.delete(origin.hostname)
119+
this.storage.delete(origin.hostname)
93120
this.runLookup(origin, opts, cb)
94121
return
95122
}
@@ -193,7 +220,7 @@ class DNSInstance {
193220
}
194221

195222
pickFamily (origin, ipFamily) {
196-
const records = this.#records.get(origin.hostname)?.records
223+
const records = this.storage.get(origin.hostname)?.records
197224
if (!records) {
198225
return null
199226
}
@@ -227,11 +254,13 @@ class DNSInstance {
227254
setRecords (origin, addresses) {
228255
const timestamp = Date.now()
229256
const records = { records: { 4: null, 6: null } }
257+
let minTTL = this.#maxTTL
230258
for (const record of addresses) {
231259
record.timestamp = timestamp
232260
if (typeof record.ttl === 'number') {
233261
// The record TTL is expected to be in ms
234262
record.ttl = Math.min(record.ttl, this.#maxTTL)
263+
minTTL = Math.min(minTTL, record.ttl)
235264
} else {
236265
record.ttl = this.#maxTTL
237266
}
@@ -242,11 +271,12 @@ class DNSInstance {
242271
records.records[record.family] = familyRecords
243272
}
244273

245-
this.#records.set(origin.hostname, records)
274+
// We provide a default TTL if external storage will be used without TTL per record-level support
275+
this.storage.set(origin.hostname, records, { ttl: minTTL })
246276
}
247277

248278
deleteRecords (origin) {
249-
this.#records.delete(origin.hostname)
279+
this.storage.delete(origin.hostname)
250280
}
251281

252282
getHandler (meta, opts) {
@@ -372,6 +402,17 @@ module.exports = interceptorOpts => {
372402
throw new InvalidArgumentError('Invalid pick. Must be a function')
373403
}
374404

405+
if (
406+
interceptorOpts?.storage != null &&
407+
(typeof interceptorOpts?.storage?.get !== 'function' ||
408+
typeof interceptorOpts?.storage?.set !== 'function' ||
409+
typeof interceptorOpts?.storage?.full !== 'function' ||
410+
typeof interceptorOpts?.storage?.delete !== 'function'
411+
)
412+
) {
413+
throw new InvalidArgumentError('Invalid storage. Must be a object with methods: { get, set, full, delete }')
414+
}
415+
375416
const dualStack = interceptorOpts?.dualStack ?? true
376417
let affinity
377418
if (dualStack) {
@@ -386,7 +427,8 @@ module.exports = interceptorOpts => {
386427
pick: interceptorOpts?.pick ?? null,
387428
dualStack,
388429
affinity,
389-
maxItems: interceptorOpts?.maxItems ?? Infinity
430+
maxItems: interceptorOpts?.maxItems ?? Infinity,
431+
storage: interceptorOpts?.storage
390432
}
391433

392434
const instance = new DNSInstance(opts)

test/interceptors/dns.js

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const { interceptors, Agent } = require('../..')
1515
const { dns } = interceptors
1616

1717
test('Should validate options', t => {
18-
t = tspl(t, { plan: 10 })
18+
t = tspl(t, { plan: 11 })
1919

2020
t.throws(() => dns({ dualStack: 'true' }), { code: 'UND_ERR_INVALID_ARG' })
2121
t.throws(() => dns({ dualStack: 0 }), { code: 'UND_ERR_INVALID_ARG' })
@@ -27,6 +27,7 @@ test('Should validate options', t => {
2727
t.throws(() => dns({ maxItems: -1 }), { code: 'UND_ERR_INVALID_ARG' })
2828
t.throws(() => dns({ lookup: {} }), { code: 'UND_ERR_INVALID_ARG' })
2929
t.throws(() => dns({ pick: [] }), { code: 'UND_ERR_INVALID_ARG' })
30+
t.throws(() => dns({ storage: new Map() }), { code: 'UND_ERR_INVALID_ARG' })
3031
})
3132

3233
test('Should automatically resolve IPs (dual stack)', async t => {
@@ -1727,6 +1728,130 @@ test('Should handle max cached items', async t => {
17271728
t.equal(await response3.body.text(), 'hello world! (x2)')
17281729
})
17291730

1731+
test('Should support external storage', async t => {
1732+
t = tspl(t, { plan: 9 })
1733+
1734+
let counter = 0
1735+
const server1 = createServer({ joinDuplicateHeaders: true })
1736+
const server2 = createServer({ joinDuplicateHeaders: true })
1737+
const requestOptions = {
1738+
method: 'GET',
1739+
path: '/',
1740+
headers: {
1741+
'content-type': 'application/json'
1742+
}
1743+
}
1744+
1745+
server1.on('request', (req, res) => {
1746+
res.writeHead(200, { 'content-type': 'text/plain' })
1747+
res.end('hello world!')
1748+
})
1749+
1750+
server1.listen(0)
1751+
1752+
server2.on('request', (req, res) => {
1753+
res.writeHead(200, { 'content-type': 'text/plain' })
1754+
res.end('hello world! (x2)')
1755+
})
1756+
server2.listen(0)
1757+
1758+
await Promise.all([once(server1, 'listening'), once(server2, 'listening')])
1759+
1760+
const cache = new Map()
1761+
const storage = {
1762+
get (origin) {
1763+
return cache.get(origin)
1764+
},
1765+
set (origin, records) {
1766+
cache.set(origin, records)
1767+
},
1768+
delete (origin) {
1769+
cache.delete(origin)
1770+
},
1771+
// simulate internal DNSStorage behaviour with `maxItems: 1` parameter
1772+
full () {
1773+
return cache.size === 1
1774+
}
1775+
}
1776+
1777+
const client = new Agent().compose([
1778+
dispatch => {
1779+
return (opts, handler) => {
1780+
++counter
1781+
const url = new URL(opts.origin)
1782+
1783+
switch (counter) {
1784+
case 1:
1785+
t.equal(isIP(url.hostname), 4)
1786+
break
1787+
1788+
case 2:
1789+
// [::1] -> ::1
1790+
t.equal(isIP(url.hostname.slice(1, 4)), 6)
1791+
break
1792+
1793+
case 3:
1794+
t.equal(url.hostname, 'developer.mozilla.org')
1795+
// Rewrite origin to avoid reaching internet
1796+
opts.origin = `http://127.0.0.1:${server2.address().port}`
1797+
break
1798+
default:
1799+
t.fails('should not reach this point')
1800+
}
1801+
1802+
return dispatch(opts, handler)
1803+
}
1804+
},
1805+
dns({
1806+
storage,
1807+
lookup: (_origin, _opts, cb) => {
1808+
cb(null, [
1809+
{
1810+
address: '::1',
1811+
family: 6
1812+
},
1813+
{
1814+
address: '127.0.0.1',
1815+
family: 4
1816+
}
1817+
])
1818+
}
1819+
})
1820+
])
1821+
1822+
after(async () => {
1823+
await client.close()
1824+
server1.close()
1825+
server2.close()
1826+
1827+
await Promise.all([once(server1, 'close'), once(server2, 'close')])
1828+
})
1829+
1830+
const response = await client.request({
1831+
...requestOptions,
1832+
origin: `http://localhost:${server1.address().port}`
1833+
})
1834+
1835+
t.equal(response.statusCode, 200)
1836+
t.equal(await response.body.text(), 'hello world!')
1837+
1838+
const response2 = await client.request({
1839+
...requestOptions,
1840+
origin: `http://localhost:${server1.address().port}`
1841+
})
1842+
1843+
t.equal(response2.statusCode, 200)
1844+
t.equal(await response2.body.text(), 'hello world!')
1845+
1846+
const response3 = await client.request({
1847+
...requestOptions,
1848+
origin: 'https://developer.mozilla.org'
1849+
})
1850+
1851+
t.equal(response3.statusCode, 200)
1852+
t.equal(await response3.body.text(), 'hello world! (x2)')
1853+
})
1854+
17301855
test('retry once with dual-stack', async t => {
17311856
t = tspl(t, { plan: 2 })
17321857

0 commit comments

Comments
 (0)