├── CNAME
├── .eslintignore
├── .gitattributes
├── .npmrc
├── .remarkrc.js
├── favicon.ico
├── ava.config.js
├── media
├── footer.png
└── header.png
├── .commitlintrc.js
├── .prettierrc.js
├── .lintstagedrc.js
├── .editorconfig
├── .gitignore
├── .xo-config.js
├── config.js
├── .github
└── workflows
│ ├── ci.yml
│ └── daily-benchmarks.yml
├── LICENSE
├── index.html
├── benchmark_results_node_v22.21.1.json
├── benchmark_results_node_v24.12.0.json
├── benchmark_results_node_v18.20.8.json
├── benchmark_results_node_latest.json
├── benchmark_results_node_22.json
├── benchmark_results_node_18.json
├── benchmark_results_node_v20.19.6.json
├── benchmark_results_node_v25.2.1.json
├── benchmark_results_node_20.json
├── benchmark_results_node_24.json
├── benchmarks
├── lookup.js
├── reverse.js
├── resolve.js
└── http.js
├── package.json
├── scripts
└── update-readme.js
├── index.d.ts
├── test
└── test.js
└── README.md
/CNAME:
--------------------------------------------------------------------------------
1 | tangeri.ne
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | !.*.js
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.remarkrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['preset-github']
3 | };
4 |
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/forwardemail/tangerine/HEAD/favicon.ico
--------------------------------------------------------------------------------
/ava.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | files: ['test/*.js', 'test/**/*.js']
3 | };
4 |
--------------------------------------------------------------------------------
/media/footer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/forwardemail/tangerine/HEAD/media/footer.png
--------------------------------------------------------------------------------
/media/header.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/forwardemail/tangerine/HEAD/media/header.png
--------------------------------------------------------------------------------
/.commitlintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional']
3 | };
4 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | bracketSpacing: true,
4 | trailingComma: 'none'
5 | };
6 |
--------------------------------------------------------------------------------
/.lintstagedrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "*.md": filenames => filenames.map(filename => `remark ${filename} -qfo`),
3 | 'package.json': 'fixpack',
4 | '*.js': 'xo --fix'
5 | };
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 | .idea
4 | node_modules
5 | coverage
6 | .nyc_output
7 | locales/
8 | package-lock.json
9 | yarn.lock
10 |
11 | Thumbs.db
12 | tmp/
13 | temp/
14 | *.lcov
15 | .env
--------------------------------------------------------------------------------
/.xo-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | prettier: true,
3 | space: true,
4 | extends: ['xo-lass'],
5 | rules: {
6 | // Disable this rule due to compatibility issues with ESLint on Node.js 18
7 | 'unicorn/expiring-todo-comments': 'off'
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-undef
2 | docute.init({
3 | debug: true,
4 | title: 'Tangerine',
5 | repo: 'forwardemail/nodejs-dns-over-https-tangerine',
6 | 'edit-link':
7 | 'https://github.com/forwardemail/nodejs-dns-over-https-tangerine/tree/main/',
8 | nav: {
9 | default: [
10 | {
11 | title:
12 | 'The best Node.js drop-in replacement for dns using DNS over HTTPS',
13 | path: '/'
14 | },
15 | {
16 | title: 'Options',
17 | path: '#options'
18 | }
19 | ]
20 | },
21 | // eslint-disable-next-line no-undef
22 | plugins: [docuteEmojify()]
23 | });
24 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | - push
4 | - pull_request
5 | jobs:
6 | build:
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | os:
11 | - ubuntu-latest
12 | node_version:
13 | - 18
14 | - 20
15 | - 22
16 | - 24
17 | name: Node ${{ matrix.node_version }} on ${{ matrix.os }}
18 | steps:
19 | - uses: actions/checkout@v3
20 | - name: Setup node
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: ${{ matrix.node_version }}
24 | - name: Install dependencies
25 | run: npm install
26 | - name: Run tests
27 | run: npm run test
28 | - name: Run benchmarks
29 | run: npm run benchmarks
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Forward Email (https://forwardemail.net)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 | The best Node.js drop-in replacement for dns using DNS over HTTPS
14 |
19 |
20 |
24 |
28 |
29 |
30 |
34 |
35 |
36 |
37 |
38 |
42 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/benchmark_results_node_v22.21.1.json:
--------------------------------------------------------------------------------
1 | {
2 | "node_version": "v22.21.1",
3 | "platform": "linux",
4 | "arch": "x64",
5 | "timestamp": "2025-12-21T16:00:49.112Z",
6 | "benchmarks": {
7 | "lookup": "Started: lookup\ntangerine.lookup POST with caching using Cloudflare x 330,006 ops/sec ±7.57% (90 runs sampled)\ntangerine.lookup POST without caching using Cloudflare x 287 ops/sec ±1.96% (84 runs sampled)\ntangerine.lookup GET with caching using Cloudflare x 324,567 ops/sec ±0.28% (89 runs sampled)\ntangerine.lookup GET without caching using Cloudflare x 311 ops/sec ±2.03% (79 runs sampled)\ndns.promises.lookup with caching using Cloudflare x 9,729,406 ops/sec ±0.59% (87 runs sampled)\ndns.promises.lookup without caching using Cloudflare x 3,169 ops/sec ±0.81% (87 runs sampled)\nFastest without caching is: dns.promises.lookup without caching using Cloudflare\n\n",
8 | "resolve": "Started: resolve\ntangerine.resolve POST with caching using Cloudflare x 1,150,690 ops/sec ±0.43% (90 runs sampled)\ntangerine.resolve POST without caching using Cloudflare x 284 ops/sec ±1.36% (82 runs sampled)\ntangerine.resolve GET with caching using Cloudflare x 1,123,967 ops/sec ±0.24% (89 runs sampled)\ntangerine.resolve GET without caching using Cloudflare x 335 ops/sec ±1.95% (80 runs sampled)\ntangerine.resolve POST with caching using Google x 1,125,905 ops/sec ±0.21% (89 runs sampled)\ntangerine.resolve POST without caching using Google x 318 ops/sec ±11.57% (77 runs sampled)\ntangerine.resolve GET with caching using Google x 1,128,787 ops/sec ±0.22% (89 runs sampled)\ntangerine.resolve GET without caching using Google x 462 ops/sec ±5.05% (80 runs sampled)\nresolver.resolve with caching using Cloudflare x 8,204,435 ops/sec ±0.57% (86 runs sampled)\nresolver.resolve without caching using Cloudflare x 55.98 ops/sec ±172.36% (78 runs sampled)\nFastest without caching is: tangerine.resolve GET without caching using Google\n\n",
9 | "reverse": "spawnSync /bin/sh ETIMEDOUT"
10 | }
11 | }
--------------------------------------------------------------------------------
/benchmark_results_node_v24.12.0.json:
--------------------------------------------------------------------------------
1 | {
2 | "node_version": "v24.12.0",
3 | "platform": "linux",
4 | "arch": "x64",
5 | "timestamp": "2025-12-21T15:54:47.975Z",
6 | "benchmarks": {
7 | "lookup": "Started: lookup\ntangerine.lookup POST with caching using Cloudflare x 1,775 ops/sec ±194.98% (90 runs sampled)\ntangerine.lookup POST without caching using Cloudflare x 295 ops/sec ±10.41% (81 runs sampled)\ntangerine.lookup GET with caching using Cloudflare x 328,666 ops/sec ±0.25% (90 runs sampled)\ntangerine.lookup GET without caching using Cloudflare x 318 ops/sec ±2.96% (80 runs sampled)\ndns.promises.lookup with caching using Cloudflare x 10,219,888 ops/sec ±0.98% (84 runs sampled)\ndns.promises.lookup without caching using Cloudflare x 3,280 ops/sec ±0.70% (84 runs sampled)\nFastest without caching is: dns.promises.lookup without caching using Cloudflare\n\n",
8 | "resolve": "Started: resolve\ntangerine.resolve POST with caching using Cloudflare x 1,164,729 ops/sec ±0.27% (90 runs sampled)\ntangerine.resolve POST without caching using Cloudflare x 316 ops/sec ±1.55% (82 runs sampled)\ntangerine.resolve GET with caching using Cloudflare x 1,135,170 ops/sec ±0.25% (90 runs sampled)\ntangerine.resolve GET without caching using Cloudflare x 355 ops/sec ±1.42% (83 runs sampled)\ntangerine.resolve POST with caching using Google x 1,120,904 ops/sec ±0.27% (90 runs sampled)\ntangerine.resolve POST without caching using Google x 427 ops/sec ±6.35% (78 runs sampled)\ntangerine.resolve GET with caching using Google x 1,104,301 ops/sec ±0.50% (90 runs sampled)\ntangerine.resolve GET without caching using Google x 418 ops/sec ±2.35% (79 runs sampled)\nresolver.resolve with caching using Cloudflare x 8,667,172 ops/sec ±0.64% (87 runs sampled)\nresolver.resolve without caching using Cloudflare x 0.14 ops/sec ±85.32% (5 runs sampled)\nFastest without caching is: tangerine.resolve GET without caching using Google\n\n",
9 | "reverse": "spawnSync /bin/sh ETIMEDOUT"
10 | }
11 | }
--------------------------------------------------------------------------------
/benchmark_results_node_v18.20.8.json:
--------------------------------------------------------------------------------
1 | {
2 | "node_version": "v18.20.8",
3 | "platform": "linux",
4 | "arch": "x64",
5 | "timestamp": "2025-12-21T15:49:20.119Z",
6 | "benchmarks": {
7 | "lookup": "Started: lookup\ntangerine.lookup POST with caching using Cloudflare x 757 ops/sec ±195.51% (88 runs sampled)\ntangerine.lookup POST without caching using Cloudflare x 120 ops/sec ±1.43% (81 runs sampled)\ntangerine.lookup GET with caching using Cloudflare x 287,666 ops/sec ±1.59% (87 runs sampled)\ntangerine.lookup GET without caching using Cloudflare x 114 ops/sec ±1.25% (78 runs sampled)\ndns.promises.lookup with caching using Cloudflare x 8,803,764 ops/sec ±0.56% (87 runs sampled)\ndns.promises.lookup without caching using Cloudflare x 3,214 ops/sec ±0.63% (84 runs sampled)\nFastest without caching is: dns.promises.lookup without caching using Cloudflare\n\n",
8 | "resolve": "Started: resolve\ntangerine.resolve POST with caching using Cloudflare x 953 ops/sec ±195.82% (88 runs sampled)\ntangerine.resolve POST without caching using Cloudflare x 116 ops/sec ±1.16% (80 runs sampled)\ntangerine.resolve GET with caching using Cloudflare x 1,004,568 ops/sec ±0.26% (87 runs sampled)\ntangerine.resolve GET without caching using Cloudflare x 120 ops/sec ±0.99% (81 runs sampled)\ntangerine.resolve POST with caching using Google x 1,001,702 ops/sec ±0.27% (88 runs sampled)\ntangerine.resolve POST without caching using Google x 126 ops/sec ±1.83% (85 runs sampled)\ntangerine.resolve GET with caching using Google x 998,942 ops/sec ±0.34% (89 runs sampled)\ntangerine.resolve GET without caching using Google x 116 ops/sec ±3.46% (79 runs sampled)\nresolver.resolve with caching using Cloudflare x 6,830,596 ops/sec ±1.01% (86 runs sampled)\nresolver.resolve without caching using Cloudflare x 5.39 ops/sec ±235.57% (7 runs sampled)\nFastest without caching is: tangerine.resolve POST without caching using Google\n\n",
9 | "reverse": "Started: reverse\ntangerine.reverse GET with caching x 628 ops/sec ±195.51% (84 runs sampled)\ntangerine.reverse GET without caching x 118 ops/sec ±1.12% (80 runs sampled)\nresolver.reverse with caching x 0.10 ops/sec ±0.02% (5 runs sampled)\nresolver.reverse without caching x 0.11 ops/sec ±30.81% (5 runs sampled)\ndns.promises.reverse with caching x 4.56 ops/sec ±196.00% (82 runs sampled)\ndns.promises.reverse without caching x 145 ops/sec ±1.36% (86 runs sampled)\nFastest without caching is: dns.promises.reverse without caching\n\n"
10 | }
11 | }
--------------------------------------------------------------------------------
/benchmark_results_node_latest.json:
--------------------------------------------------------------------------------
1 |
2 | > tangerine@1.6.0 benchmarks
3 | > node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse --json
4 |
5 | Started: lookup
6 | tangerine.lookup POST with caching using Cloudflare x 1,717 ops/sec ±195.06% (87 runs sampled)
7 | tangerine.lookup POST without caching using Cloudflare x 115 ops/sec ±2.47% (79 runs sampled)
8 | tangerine.lookup GET with caching using Cloudflare x 347,550 ops/sec ±1.13% (90 runs sampled)
9 | tangerine.lookup GET without caching using Cloudflare x 122 ops/sec ±6.90% (85 runs sampled)
10 | dns.promises.lookup with caching using Cloudflare x 1,258 ops/sec ±195.98% (85 runs sampled)
11 | dns.promises.lookup without caching using Cloudflare x 3,244 ops/sec ±1.51% (83 runs sampled)
12 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
13 |
14 | Started: resolve
15 | tangerine.resolve POST with caching using Cloudflare x 1,329 ops/sec ±195.78% (90 runs sampled)
16 | tangerine.resolve POST without caching using Cloudflare x 127 ops/sec ±0.85% (85 runs sampled)
17 | tangerine.resolve GET with caching using Cloudflare x 1,171,079 ops/sec ±0.27% (90 runs sampled)
18 | tangerine.resolve GET without caching using Cloudflare x 112 ops/sec ±3.08% (77 runs sampled)
19 | tangerine.resolve POST with caching using Google x 1,158,897 ops/sec ±2.29% (89 runs sampled)
20 | tangerine.resolve POST without caching using Google x 120 ops/sec ±5.21% (83 runs sampled)
21 | tangerine.resolve GET with caching using Google x 1,170,400 ops/sec ±0.26% (90 runs sampled)
22 | tangerine.resolve GET without caching using Google x 118 ops/sec ±0.74% (80 runs sampled)
23 | resolver.resolve with caching using Cloudflare x 8,523,933 ops/sec ±2.46% (88 runs sampled)
24 | resolver.resolve without caching using Cloudflare x 148 ops/sec ±1.19% (87 runs sampled)
25 | Fastest without caching is: resolver.resolve without caching using Cloudflare
26 |
27 | Started: reverse
28 | tangerine.reverse GET with caching x 337,871 ops/sec ±8.47% (90 runs sampled)
29 | tangerine.reverse GET without caching x 129 ops/sec ±0.62% (86 runs sampled)
30 | resolver.reverse with caching x 8,974,569 ops/sec ±0.55% (87 runs sampled)
31 | resolver.reverse without caching x 153 ops/sec ±0.54% (83 runs sampled)
32 | dns.promises.reverse with caching x 8,939,948 ops/sec ±2.01% (86 runs sampled)
33 | dns.promises.reverse without caching x 3.27 ops/sec ±191.87% (82 runs sampled)
34 | Fastest without caching is: resolver.reverse without caching
35 |
36 |
--------------------------------------------------------------------------------
/benchmark_results_node_22.json:
--------------------------------------------------------------------------------
1 |
2 | > tangerine@1.6.0 benchmarks
3 | > node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse --json
4 |
5 | Started: lookup
6 | tangerine.lookup POST with caching using Cloudflare x 325,702 ops/sec ±2.98% (87 runs sampled)
7 | tangerine.lookup POST without caching using Cloudflare x 306 ops/sec ±2.07% (81 runs sampled)
8 | tangerine.lookup GET with caching using Cloudflare x 323,077 ops/sec ±0.46% (89 runs sampled)
9 | tangerine.lookup GET without caching using Cloudflare x 304 ops/sec ±2.26% (80 runs sampled)
10 | dns.promises.lookup with caching using Cloudflare x 9,651,572 ops/sec ±0.66% (88 runs sampled)
11 | dns.promises.lookup without caching using Cloudflare x 3,180 ops/sec ±0.77% (86 runs sampled)
12 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
13 |
14 | Started: resolve
15 | tangerine.resolve POST with caching using Cloudflare x 1,154,301 ops/sec ±0.45% (89 runs sampled)
16 | tangerine.resolve POST without caching using Cloudflare x 288 ops/sec ±1.21% (81 runs sampled)
17 | tangerine.resolve GET with caching using Cloudflare x 1,122,780 ops/sec ±0.22% (90 runs sampled)
18 | tangerine.resolve GET without caching using Cloudflare x 323 ops/sec ±1.28% (84 runs sampled)
19 | tangerine.resolve POST with caching using Google x 1,125,992 ops/sec ±0.17% (89 runs sampled)
20 | tangerine.resolve POST without caching using Google x 346 ops/sec ±9.22% (85 runs sampled)
21 | tangerine.resolve GET with caching using Google x 1,128,448 ops/sec ±0.20% (90 runs sampled)
22 | tangerine.resolve GET without caching using Google x 368 ops/sec ±10.95% (79 runs sampled)
23 | resolver.resolve with caching using Cloudflare x 8,311,236 ops/sec ±0.78% (84 runs sampled)
24 | resolver.resolve without caching using Cloudflare x 39.19 ops/sec ±179.81% (76 runs sampled)
25 | Fastest without caching is: tangerine.resolve GET without caching using Google
26 |
27 | Started: reverse
28 | tangerine.reverse GET with caching x 348,511 ops/sec ±0.44% (88 runs sampled)
29 | tangerine.reverse GET without caching x 292 ops/sec ±1.64% (81 runs sampled)
30 | resolver.reverse with caching x 0.09 ops/sec ±5.09% (5 runs sampled)
31 | resolver.reverse without caching x 2.45 ops/sec ±101.88% (80 runs sampled)
32 | dns.promises.reverse with caching x 26.64 ops/sec ±196.00% (80 runs sampled)
33 | dns.promises.reverse without caching x 3.31 ops/sec ±114.94% (79 runs sampled)
34 | Fastest without caching is: tangerine.reverse GET without caching
35 |
36 |
--------------------------------------------------------------------------------
/benchmark_results_node_18.json:
--------------------------------------------------------------------------------
1 |
2 | > tangerine@1.6.0 benchmarks
3 | > node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse --json
4 |
5 | Started: lookup
6 | tangerine.lookup POST with caching using Cloudflare x 754 ops/sec ±195.51% (89 runs sampled)
7 | tangerine.lookup POST without caching using Cloudflare x 119 ops/sec ±1.40% (80 runs sampled)
8 | tangerine.lookup GET with caching using Cloudflare x 289,700 ops/sec ±0.47% (89 runs sampled)
9 | tangerine.lookup GET without caching using Cloudflare x 111 ops/sec ±1.58% (75 runs sampled)
10 | dns.promises.lookup with caching using Cloudflare x 8,510,683 ops/sec ±2.02% (87 runs sampled)
11 | dns.promises.lookup without caching using Cloudflare x 3,226 ops/sec ±0.88% (84 runs sampled)
12 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
13 |
14 | Started: resolve
15 | tangerine.resolve POST with caching using Cloudflare x 957 ops/sec ±195.82% (87 runs sampled)
16 | tangerine.resolve POST without caching using Cloudflare x 118 ops/sec ±0.95% (80 runs sampled)
17 | tangerine.resolve GET with caching using Cloudflare x 990,705 ops/sec ±0.29% (89 runs sampled)
18 | tangerine.resolve GET without caching using Cloudflare x 120 ops/sec ±1.22% (81 runs sampled)
19 | tangerine.resolve POST with caching using Google x 987,067 ops/sec ±0.43% (88 runs sampled)
20 | tangerine.resolve POST without caching using Google x 124 ops/sec ±0.99% (84 runs sampled)
21 | tangerine.resolve GET with caching using Google x 995,033 ops/sec ±0.22% (89 runs sampled)
22 | tangerine.resolve GET without caching using Google x 120 ops/sec ±0.88% (81 runs sampled)
23 | resolver.resolve with caching using Cloudflare x 6,616,154 ops/sec ±0.88% (87 runs sampled)
24 | resolver.resolve without caching using Cloudflare x 143 ops/sec ±1.57% (84 runs sampled)
25 | Fastest without caching is: resolver.resolve without caching using Cloudflare
26 |
27 | Started: reverse
28 | tangerine.reverse GET with caching x 755 ops/sec ±195.49% (87 runs sampled)
29 | tangerine.reverse GET without caching x 118 ops/sec ±1.27% (80 runs sampled)
30 | resolver.reverse with caching x 7,280,839 ops/sec ±1.15% (86 runs sampled)
31 | resolver.reverse without caching x 146 ops/sec ±1.42% (85 runs sampled)
32 | dns.promises.reverse with caching x 7,288,151 ops/sec ±1.19% (84 runs sampled)
33 | dns.promises.reverse without caching x 148 ops/sec ±0.84% (87 runs sampled)
34 | Fastest without caching is: dns.promises.reverse without caching, resolver.reverse without caching
35 |
36 |
--------------------------------------------------------------------------------
/benchmark_results_node_v20.19.6.json:
--------------------------------------------------------------------------------
1 | {
2 | "node_version": "v20.19.6",
3 | "platform": "linux",
4 | "arch": "x64",
5 | "timestamp": "2025-12-25T02:48:59.683Z",
6 | "benchmarks": {
7 | "lookup": "Started: lookup\ntangerine.lookup POST with caching using Cloudflare x 717 ops/sec ±195.56% (86 runs sampled)\ntangerine.lookup POST without caching using Cloudflare x 127 ops/sec ±1.19% (85 runs sampled)\ntangerine.lookup GET with caching using Cloudflare x 300,110 ops/sec ±0.27% (90 runs sampled)\ntangerine.lookup GET without caching using Cloudflare x 124 ops/sec ±1.14% (84 runs sampled)\ndns.promises.lookup with caching using Cloudflare x 9,245,446 ops/sec ±0.55% (88 runs sampled)\ndns.promises.lookup without caching using Cloudflare x 3,172 ops/sec ±0.71% (86 runs sampled)\nFastest without caching is: dns.promises.lookup without caching using Cloudflare\n\n",
8 | "resolve": "Started: resolve\ntangerine.resolve POST with caching using Cloudflare x 715 ops/sec ±195.87% (88 runs sampled)\ntangerine.resolve POST without caching using Cloudflare x 128 ops/sec ±0.60% (86 runs sampled)\ntangerine.resolve GET with caching using Cloudflare x 1,071,910 ops/sec ±0.26% (89 runs sampled)\ntangerine.resolve GET without caching using Cloudflare x 131 ops/sec ±0.42% (87 runs sampled)\ntangerine.resolve POST with caching using Google x 1,014,907 ops/sec ±0.32% (89 runs sampled)\ntangerine.resolve POST without caching using Google x 117 ops/sec ±0.76% (80 runs sampled)\ntangerine.resolve GET with caching using Google x 1,015,562 ops/sec ±0.25% (89 runs sampled)\ntangerine.resolve GET without caching using Google x 119 ops/sec ±5.40% (82 runs sampled)\nresolver.resolve with caching using Cloudflare x 3.15 ops/sec ±115.10% (85 runs sampled)\nresolver.resolve without caching using Cloudflare x 10.56 ops/sec ±148.53% (79 runs sampled)\nFastest without caching is: tangerine.resolve GET without caching using Cloudflare\n\n",
9 | "reverse": "Started: reverse\ntangerine.reverse GET with caching x 872 ops/sec ±195.46% (89 runs sampled)\ntangerine.reverse GET without caching x 128 ops/sec ±0.59% (86 runs sampled)\nresolver.reverse with caching x 8,239,464 ops/sec ±1.02% (88 runs sampled)\nresolver.reverse without caching x 154 ops/sec ±0.42% (90 runs sampled)\ndns.promises.reverse with caching x 8,349,302 ops/sec ±0.43% (90 runs sampled)\ndns.promises.reverse without caching x 23.17 ops/sec ±166.35% (85 runs sampled)\nFastest without caching is: resolver.reverse without caching, dns.promises.reverse without caching\n\n"
10 | }
11 | }
--------------------------------------------------------------------------------
/benchmark_results_node_v25.2.1.json:
--------------------------------------------------------------------------------
1 | {
2 | "node_version": "v25.2.1",
3 | "platform": "linux",
4 | "arch": "x64",
5 | "timestamp": "2025-12-21T15:52:58.213Z",
6 | "benchmarks": {
7 | "lookup": "Started: lookup\ntangerine.lookup POST with caching using Cloudflare x 1,504 ops/sec ±195.19% (89 runs sampled)\ntangerine.lookup POST without caching using Cloudflare x 118 ops/sec ±2.46% (81 runs sampled)\ntangerine.lookup GET with caching using Cloudflare x 341,247 ops/sec ±0.36% (90 runs sampled)\ntangerine.lookup GET without caching using Cloudflare x 119 ops/sec ±6.76% (84 runs sampled)\ndns.promises.lookup with caching using Cloudflare x 10,273,047 ops/sec ±1.94% (84 runs sampled)\ndns.promises.lookup without caching using Cloudflare x 3,255 ops/sec ±1.11% (86 runs sampled)\nFastest without caching is: dns.promises.lookup without caching using Cloudflare\n\n",
8 | "resolve": "Started: resolve\ntangerine.resolve POST with caching using Cloudflare x 1,200,168 ops/sec ±1.70% (90 runs sampled)\ntangerine.resolve POST without caching using Cloudflare x 132 ops/sec ±0.46% (88 runs sampled)\ntangerine.resolve GET with caching using Cloudflare x 1,179,037 ops/sec ±0.31% (89 runs sampled)\ntangerine.resolve GET without caching using Cloudflare x 120 ops/sec ±0.89% (81 runs sampled)\ntangerine.resolve POST with caching using Google x 1,159,414 ops/sec ±2.50% (89 runs sampled)\ntangerine.resolve POST without caching using Google x 122 ops/sec ±3.58% (83 runs sampled)\ntangerine.resolve GET with caching using Google x 1,166,324 ops/sec ±3.01% (88 runs sampled)\ntangerine.resolve GET without caching using Google x 120 ops/sec ±0.48% (81 runs sampled)\nresolver.resolve with caching using Cloudflare x 8,890,562 ops/sec ±2.49% (82 runs sampled)\nresolver.resolve without caching using Cloudflare x 147 ops/sec ±1.08% (86 runs sampled)\nFastest without caching is: resolver.resolve without caching using Cloudflare\n\n",
9 | "reverse": "Started: reverse\ntangerine.reverse GET with caching x 342,190 ops/sec ±8.40% (90 runs sampled)\ntangerine.reverse GET without caching x 128 ops/sec ±0.83% (86 runs sampled)\nresolver.reverse with caching x 9,145,218 ops/sec ±0.55% (85 runs sampled)\nresolver.reverse without caching x 155 ops/sec ±0.63% (82 runs sampled)\ndns.promises.reverse with caching x 9,157,969 ops/sec ±0.45% (89 runs sampled)\ndns.promises.reverse without caching x 5.11 ops/sec ±189.52% (76 runs sampled)\nFastest without caching is: resolver.reverse without caching, dns.promises.reverse without caching\n\n"
10 | }
11 | }
--------------------------------------------------------------------------------
/benchmark_results_node_20.json:
--------------------------------------------------------------------------------
1 |
2 | > tangerine@2.0.2 benchmarks
3 | > node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse --json
4 |
5 | Started: lookup
6 | tangerine.lookup POST with caching using Cloudflare x 845 ops/sec ±195.45% (90 runs sampled)
7 | tangerine.lookup POST without caching using Cloudflare x 125 ops/sec ±1.26% (84 runs sampled)
8 | tangerine.lookup GET with caching using Cloudflare x 304,799 ops/sec ±0.40% (89 runs sampled)
9 | tangerine.lookup GET without caching using Cloudflare x 123 ops/sec ±1.06% (83 runs sampled)
10 | dns.promises.lookup with caching using Cloudflare x 9,374,553 ops/sec ±1.41% (88 runs sampled)
11 | dns.promises.lookup without caching using Cloudflare x 3,168 ops/sec ±0.76% (85 runs sampled)
12 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
13 |
14 | Started: resolve
15 | tangerine.resolve POST with caching using Cloudflare x 758 ops/sec ±195.86% (90 runs sampled)
16 | tangerine.resolve POST without caching using Cloudflare x 127 ops/sec ±0.67% (85 runs sampled)
17 | tangerine.resolve GET with caching using Cloudflare x 1,040,693 ops/sec ±0.32% (89 runs sampled)
18 | tangerine.resolve GET without caching using Cloudflare x 128 ops/sec ±0.56% (86 runs sampled)
19 | tangerine.resolve POST with caching using Google x 1,082,104 ops/sec ±0.42% (90 runs sampled)
20 | tangerine.resolve POST without caching using Google x 120 ops/sec ±0.77% (81 runs sampled)
21 | tangerine.resolve GET with caching using Google x 1,075,346 ops/sec ±0.31% (89 runs sampled)
22 | tangerine.resolve GET without caching using Google x 124 ops/sec ±1.01% (84 runs sampled)
23 | resolver.resolve with caching using Cloudflare x 8,187,527 ops/sec ±0.41% (88 runs sampled)
24 | resolver.resolve without caching using Cloudflare x 151 ops/sec ±0.71% (87 runs sampled)
25 | Fastest without caching is: resolver.resolve without caching using Cloudflare
26 |
27 | Started: reverse
28 | tangerine.reverse GET with caching x 832 ops/sec ±195.45% (88 runs sampled)
29 | tangerine.reverse GET without caching x 128 ops/sec ±0.61% (86 runs sampled)
30 | resolver.reverse with caching x 8,158,447 ops/sec ±0.96% (89 runs sampled)
31 | resolver.reverse without caching x 153 ops/sec ±0.53% (81 runs sampled)
32 | dns.promises.reverse with caching x 8,174,347 ops/sec ±0.45% (88 runs sampled)
33 | dns.promises.reverse without caching x 6.71 ops/sec ±187.37% (78 runs sampled)
34 | Fastest without caching is: resolver.reverse without caching, dns.promises.reverse without caching
35 |
36 |
--------------------------------------------------------------------------------
/benchmark_results_node_24.json:
--------------------------------------------------------------------------------
1 |
2 | > tangerine@1.6.0 benchmarks
3 | > node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse --json
4 |
5 | Started: lookup
6 | tangerine.lookup POST with caching using Cloudflare x 1,444 ops/sec ±195.16% (90 runs sampled)
7 | tangerine.lookup POST without caching using Cloudflare x 313 ops/sec ±2.62% (81 runs sampled)
8 | tangerine.lookup GET with caching using Cloudflare x 325,790 ops/sec ±0.71% (90 runs sampled)
9 | tangerine.lookup GET without caching using Cloudflare x 318 ops/sec ±1.47% (81 runs sampled)
10 | dns.promises.lookup with caching using Cloudflare x 10,093,000 ops/sec ±1.07% (85 runs sampled)
11 | dns.promises.lookup without caching using Cloudflare x 3,299 ops/sec ±0.76% (84 runs sampled)
12 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
13 |
14 | Started: resolve
15 | tangerine.resolve POST with caching using Cloudflare x 1,177,287 ops/sec ±0.59% (88 runs sampled)
16 | tangerine.resolve POST without caching using Cloudflare x 306 ops/sec ±1.55% (81 runs sampled)
17 | tangerine.resolve GET with caching using Cloudflare x 1,132,908 ops/sec ±0.26% (90 runs sampled)
18 | tangerine.resolve GET without caching using Cloudflare x 349 ops/sec ±1.79% (80 runs sampled)
19 | tangerine.resolve POST with caching using Google x 1,132,264 ops/sec ±0.24% (89 runs sampled)
20 | tangerine.resolve POST without caching using Google x 424 ops/sec ±6.14% (81 runs sampled)
21 | tangerine.resolve GET with caching using Google x 1,130,549 ops/sec ±0.54% (90 runs sampled)
22 | tangerine.resolve GET without caching using Google x 363 ops/sec ±12.36% (72 runs sampled)
23 | resolver.resolve with caching using Cloudflare x 8,777,489 ops/sec ±0.71% (87 runs sampled)
24 | resolver.resolve without caching using Cloudflare x 42.85 ops/sec ±177.75% (80 runs sampled)
25 | Fastest without caching is: tangerine.resolve POST without caching using Google, resolver.resolve without caching using Cloudflare
26 |
27 | Started: reverse
28 | tangerine.reverse GET with caching x 328,371 ops/sec ±8.01% (90 runs sampled)
29 | tangerine.reverse GET without caching x 312 ops/sec ±1.32% (83 runs sampled)
30 | resolver.reverse with caching x 0.10 ops/sec ±27.48% (5 runs sampled)
31 | resolver.reverse without caching x 0.10 ops/sec ±35.38% (5 runs sampled)
32 | dns.promises.reverse with caching x 8,427,431 ops/sec ±1.34% (79 runs sampled)
33 | dns.promises.reverse without caching x 21.05 ops/sec ±187.05% (79 runs sampled)
34 | Fastest without caching is: tangerine.reverse GET without caching
35 |
36 |
--------------------------------------------------------------------------------
/benchmarks/lookup.js:
--------------------------------------------------------------------------------
1 | const dns = require('node:dns');
2 | const Benchmark = require('benchmark');
3 | const Tangerine = require('..');
4 |
5 | const opts = { timeout: 5000, tries: 1 };
6 |
7 | // eslint-disable-next-line n/prefer-promises/dns
8 | dns.setServers(['1.1.1.1', '1.0.0.1']);
9 |
10 | const resolver = new dns.promises.Resolver(opts);
11 | resolver.setServers(['1.1.1.1', '1.0.0.1']);
12 |
13 | const cache = new Map();
14 |
15 | async function lookupWithCache(host) {
16 | let result = cache.get(host);
17 | if (result) return result;
18 | result = await dns.promises.lookup(host);
19 | if (result) cache.set(host, result);
20 | return result;
21 | }
22 |
23 | const tangerine = new Tangerine({ ...opts, method: 'POST' });
24 | const tangerineNoCache = new Tangerine({
25 | ...opts,
26 | method: 'POST',
27 | cache: false
28 | });
29 | const tangerineGet = new Tangerine(opts);
30 | const tangerineGetNoCache = new Tangerine({ ...opts, cache: false });
31 |
32 | const host = 'netflix.com';
33 |
34 | const suite = new Benchmark.Suite('lookup');
35 |
36 | suite.on('start', function (ev) {
37 | console.log(`Started: ${ev.currentTarget.name}`);
38 | });
39 |
40 | suite.add('tangerine.lookup POST with caching using Cloudflare', {
41 | defer: true,
42 | async fn(deferred) {
43 | await tangerine.lookup(host);
44 | deferred.resolve();
45 | }
46 | });
47 |
48 | suite.add('tangerine.lookup POST without caching using Cloudflare', {
49 | defer: true,
50 | async fn(deferred) {
51 | await tangerineNoCache.lookup(host);
52 | deferred.resolve();
53 | }
54 | });
55 |
56 | suite.add('tangerine.lookup GET with caching using Cloudflare', {
57 | defer: true,
58 | async fn(deferred) {
59 | await tangerineGet.lookup(host);
60 | deferred.resolve();
61 | }
62 | });
63 |
64 | suite.add('tangerine.lookup GET without caching using Cloudflare', {
65 | defer: true,
66 | async fn(deferred) {
67 | await tangerineGetNoCache.lookup(host);
68 | deferred.resolve();
69 | }
70 | });
71 |
72 | suite.add('dns.promises.lookup with caching using Cloudflare', {
73 | defer: true,
74 | async fn(deferred) {
75 | try {
76 | await lookupWithCache(host);
77 | } catch {}
78 |
79 | deferred.resolve();
80 | }
81 | });
82 |
83 | suite.add('dns.promises.lookup without caching using Cloudflare', {
84 | defer: true,
85 | async fn(deferred) {
86 | try {
87 | await dns.promises.lookup(host);
88 | } catch {}
89 |
90 | deferred.resolve();
91 | }
92 | });
93 |
94 | suite.on('cycle', (ev) => {
95 | console.log(String(ev.target));
96 | });
97 |
98 | suite.on('complete', function () {
99 | console.log(
100 | `Fastest without caching is: ${this.filter((bench) =>
101 | bench.name.includes('without caching')
102 | )
103 | .filter('fastest')
104 | .map('name')
105 | .join(', ')}\n`
106 | );
107 | });
108 |
109 | suite.run();
110 |
--------------------------------------------------------------------------------
/benchmarks/reverse.js:
--------------------------------------------------------------------------------
1 | const dns = require('node:dns');
2 | const Benchmark = require('benchmark');
3 | const Tangerine = require('..');
4 |
5 | const opts = { timeout: 5000, tries: 1 };
6 |
7 | // eslint-disable-next-line n/prefer-promises/dns
8 | dns.setServers(['1.1.1.1', '1.0.0.1']);
9 |
10 | const resolver = new dns.promises.Resolver(opts);
11 | resolver.setServers(['1.1.1.1', '1.0.0.1']);
12 |
13 | const cache = new Map();
14 |
15 | async function resolverReverseWithCache(host) {
16 | let result = cache.get(host);
17 | if (result) return result;
18 | result = await resolver.reverse(host);
19 | if (result) cache.set(host, result);
20 | return result;
21 | }
22 |
23 | async function dnsReverseWithCache(host) {
24 | let result = cache.get(host);
25 | if (result) return result;
26 | result = await dns.promises.reverse(host);
27 | if (result) cache.set(host, result);
28 | return result;
29 | }
30 |
31 | const tangerine = new Tangerine({ ...opts, method: 'POST' });
32 | const tangerineNoCache = new Tangerine({
33 | ...opts,
34 | method: 'POST',
35 | cache: false
36 | });
37 |
38 | const suite = new Benchmark.Suite('reverse');
39 |
40 | suite.on('start', function (ev) {
41 | console.log(`Started: ${ev.currentTarget.name}`);
42 | });
43 |
44 | suite.add('tangerine.reverse GET with caching', {
45 | defer: true,
46 | async fn(deferred) {
47 | try {
48 | await tangerine.reverse('1.1.1.1');
49 | } catch {}
50 |
51 | deferred.resolve();
52 | }
53 | });
54 |
55 | suite.add('tangerine.reverse GET without caching', {
56 | defer: true,
57 | async fn(deferred) {
58 | try {
59 | await tangerineNoCache.reverse('1.1.1.1');
60 | } catch {}
61 |
62 | deferred.resolve();
63 | }
64 | });
65 |
66 | suite.add('resolver.reverse with caching', {
67 | defer: true,
68 | async fn(deferred) {
69 | try {
70 | await resolverReverseWithCache('1.1.1.1');
71 | } catch {}
72 |
73 | deferred.resolve();
74 | }
75 | });
76 |
77 | suite.add('resolver.reverse without caching', {
78 | defer: true,
79 | async fn(deferred) {
80 | try {
81 | await resolver.reverse('1.1.1.1');
82 | } catch {}
83 |
84 | deferred.resolve();
85 | }
86 | });
87 |
88 | suite.add('dns.promises.reverse with caching', {
89 | defer: true,
90 | async fn(deferred) {
91 | try {
92 | await dnsReverseWithCache('1.1.1.1');
93 | } catch {}
94 |
95 | deferred.resolve();
96 | }
97 | });
98 |
99 | suite.add('dns.promises.reverse without caching', {
100 | defer: true,
101 | async fn(deferred) {
102 | try {
103 | await dns.promises.reverse('1.1.1.1');
104 | } catch {}
105 |
106 | deferred.resolve();
107 | }
108 | });
109 |
110 | suite.on('cycle', (ev) => {
111 | console.log(String(ev.target));
112 | });
113 |
114 | suite.on('complete', function () {
115 | console.log(
116 | `Fastest without caching is: ${this.filter((bench) =>
117 | bench.name.includes('without caching')
118 | )
119 | .filter('fastest')
120 | .map('name')
121 | .join(', ')}\n`
122 | );
123 | });
124 |
125 | suite.run();
126 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tangerine",
3 | "description": "Tangerine is the best Node.js drop-in replacement for dns.promises.Resolver using DNS over HTTPS (\"DoH\") via undici with built-in retries, timeouts, smart server rotation, AbortControllers, and caching support for multiple backends (with TTL and purge support).",
4 | "version": "2.0.2",
5 | "author": "Forward Email (https://forwardemail.net)",
6 | "bugs": {
7 | "url": "https://github.com/forwardemail/nodejs-dns-over-https-tangerine/issues"
8 | },
9 | "contributors": [
10 | "Forward Email (https://forwardemail.net)"
11 | ],
12 | "dependencies": {
13 | "auto-bind": "4",
14 | "dns-packet": "^5.6.1",
15 | "dohdec": "^5.0.3",
16 | "get-stream": "6",
17 | "hostile": "^1.4.0",
18 | "ipaddr.js": "^2.2.0",
19 | "is-stream": "2.0.1",
20 | "merge-options": "3.0.4",
21 | "p-map": "4",
22 | "p-wait-for": "3",
23 | "port-numbers": "6.0.1",
24 | "private-ip": "^3.0.2",
25 | "punycode": "^2.3.1",
26 | "semver": "^7.6.3"
27 | },
28 | "devDependencies": {
29 | "@commitlint/cli": "^19.3.0",
30 | "@commitlint/config-conventional": "^19.2.2",
31 | "ava": "^5.2.0",
32 | "axios": "^1.7.3",
33 | "benchmark": "^2.1.4",
34 | "cross-env": "^7.0.3",
35 | "eslint": "^9.8.0",
36 | "eslint-config-xo-lass": "^2.0.1",
37 | "fetch-mock": "^10.1.1",
38 | "fixpack": "^4.0.0",
39 | "got": "11",
40 | "husky": "^9.1.4",
41 | "ioredis": "^5.4.1",
42 | "ioredis-mock": "^8.9.0",
43 | "is-ci": "^3.0.1",
44 | "lint-staged": "^15.2.8",
45 | "lodash": "^4.17.21",
46 | "nock": "^13.5.4",
47 | "node-fetch": "2",
48 | "nyc": "^17.0.0",
49 | "phin": "^3.7.1",
50 | "remark-cli": "11.0.0",
51 | "remark-preset-github": "^4.0.4",
52 | "request": "^2.88.2",
53 | "sort-keys": "4.2.0",
54 | "superagent": "^9.0.2",
55 | "undici": "^6.19.5",
56 | "xo": "^0.58.0"
57 | },
58 | "engines": {
59 | "node": ">=18"
60 | },
61 | "files": [
62 | "index.js",
63 | "index.d.ts"
64 | ],
65 | "homepage": "https://github.com/forwardemail/nodejs-dns-over-https-tangerine",
66 | "keywords": [
67 | "1:1",
68 | "abort",
69 | "abortcontroller",
70 | "abuse",
71 | "adapter",
72 | "alternative",
73 | "api",
74 | "backend",
75 | "better",
76 | "cache",
77 | "caching",
78 | "callback",
79 | "callbacks",
80 | "cloudflare",
81 | "controller",
82 | "ddos",
83 | "dns",
84 | "doh",
85 | "drop-in",
86 | "dropin",
87 | "dummy",
88 | "email",
89 | "fast",
90 | "fe",
91 | "forward",
92 | "google",
93 | "http",
94 | "https",
95 | "lad",
96 | "layer",
97 | "lookup",
98 | "lru",
99 | "malware",
100 | "mandarin",
101 | "mechanism",
102 | "memory",
103 | "modern",
104 | "mongo",
105 | "over",
106 | "package",
107 | "phishing",
108 | "prevention",
109 | "project",
110 | "promise",
111 | "promises",
112 | "proof",
113 | "protection",
114 | "pttl",
115 | "query",
116 | "records",
117 | "redis",
118 | "replace",
119 | "replacement",
120 | "resolve",
121 | "resolver",
122 | "retries",
123 | "retry",
124 | "rotate",
125 | "rotation",
126 | "security",
127 | "server",
128 | "signal",
129 | "smart",
130 | "spam",
131 | "storage",
132 | "tangelo",
133 | "tangerine",
134 | "tangerines",
135 | "timeout",
136 | "timeouts",
137 | "ttl",
138 | "undici",
139 | "wrapper"
140 | ],
141 | "license": "MIT",
142 | "main": "index.js",
143 | "peerDependencies": {
144 | "undici": "*"
145 | },
146 | "peerDependenciesMeta": {
147 | "undici": {
148 | "optional": true
149 | }
150 | },
151 | "publishConfig": {
152 | "access": "public"
153 | },
154 | "repository": {
155 | "type": "git",
156 | "url": "https://github.com/forwardemail/nodejs-dns-over-https-tangerine"
157 | },
158 | "scripts": {
159 | "ava": "cross-env NODE_ENV=test ava",
160 | "benchmarks": "node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse",
161 | "lint": "xo --fix && remark . -qfo && fixpack",
162 | "nyc": "cross-env NODE_ENV=test nyc ava",
163 | "prepare": "husky install",
164 | "pretest": "npm run lint",
165 | "test": "npm run nyc"
166 | },
167 | "types": "index.d.ts"
168 | }
169 |
--------------------------------------------------------------------------------
/benchmarks/resolve.js:
--------------------------------------------------------------------------------
1 | const dns = require('node:dns');
2 | const Benchmark = require('benchmark');
3 | const Tangerine = require('..');
4 |
5 | const opts = { timeout: 5000, tries: 1 };
6 |
7 | // eslint-disable-next-line n/prefer-promises/dns
8 | dns.setServers(['1.1.1.1', '1.0.0.1']);
9 |
10 | const resolver = new dns.promises.Resolver(opts);
11 | resolver.setServers(['1.1.1.1', '1.0.0.1']);
12 |
13 | const cache = new Map();
14 |
15 | async function resolveWithCache(host, record) {
16 | const key = `${host}:${record}`;
17 | let result = cache.get(key);
18 | if (result) return result;
19 | result = await resolver.resolve(host, record);
20 | if (result) cache.set(key, result);
21 | return result;
22 | }
23 |
24 | const tangerine = new Tangerine({ ...opts, method: 'POST' });
25 | const tangerineNoCache = new Tangerine({
26 | ...opts,
27 | method: 'POST',
28 | cache: false
29 | });
30 | const tangerineGet = new Tangerine(opts);
31 | const tangerineGetNoCache = new Tangerine({ ...opts, cache: false });
32 |
33 | // Google servers
34 | const servers = ['8.8.8.8', '8.8.4.4'];
35 |
36 | const tangerineGoogle = new Tangerine({ ...opts, servers, method: 'POST' });
37 | const tangerineGoogleNoCache = new Tangerine({
38 | ...opts,
39 | servers,
40 | method: 'POST',
41 | cache: false
42 | });
43 | const tangerineGoogleGet = new Tangerine({ ...opts, servers });
44 | const tangerineGoogleGetNoCache = new Tangerine({
45 | ...opts,
46 | servers,
47 | cache: false
48 | });
49 |
50 | const host = 'netflix.com';
51 | const record = 'A';
52 |
53 | // ---
54 |
55 | const suite = new Benchmark.Suite('resolve');
56 |
57 | suite.on('start', function (ev) {
58 | console.log(`Started: ${ev.currentTarget.name}`);
59 | });
60 |
61 | // Cloudflare
62 | suite.add('tangerine.resolve POST with caching using Cloudflare', {
63 | defer: true,
64 | async fn(deferred) {
65 | await tangerine.resolve(host, record);
66 | deferred.resolve();
67 | }
68 | });
69 |
70 | suite.add('tangerine.resolve POST without caching using Cloudflare', {
71 | defer: true,
72 | async fn(deferred) {
73 | await tangerineNoCache.resolve(host, record);
74 | deferred.resolve();
75 | }
76 | });
77 |
78 | suite.add('tangerine.resolve GET with caching using Cloudflare', {
79 | defer: true,
80 | async fn(deferred) {
81 | await tangerineGet.resolve(host, record);
82 | deferred.resolve();
83 | }
84 | });
85 |
86 | suite.add('tangerine.resolve GET without caching using Cloudflare', {
87 | defer: true,
88 | async fn(deferred) {
89 | await tangerineGetNoCache.resolve(host, record);
90 | deferred.resolve();
91 | }
92 | });
93 |
94 | // Google
95 | suite.add('tangerine.resolve POST with caching using Google', {
96 | defer: true,
97 | async fn(deferred) {
98 | await tangerineGoogle.resolve(host, record);
99 | deferred.resolve();
100 | }
101 | });
102 |
103 | suite.add('tangerine.resolve POST without caching using Google', {
104 | defer: true,
105 | async fn(deferred) {
106 | await tangerineGoogleNoCache.resolve(host, record);
107 | deferred.resolve();
108 | }
109 | });
110 |
111 | suite.add('tangerine.resolve GET with caching using Google', {
112 | defer: true,
113 | async fn(deferred) {
114 | await tangerineGoogleGet.resolve(host, record);
115 | deferred.resolve();
116 | }
117 | });
118 |
119 | suite.add('tangerine.resolve GET without caching using Google', {
120 | defer: true,
121 | async fn(deferred) {
122 | await tangerineGoogleGetNoCache.resolve(host, record);
123 | deferred.resolve();
124 | }
125 | });
126 |
127 | suite.add('resolver.resolve with caching using Cloudflare', {
128 | defer: true,
129 | async fn(deferred) {
130 | try {
131 | await resolveWithCache(host, record);
132 | } catch {}
133 |
134 | deferred.resolve();
135 | }
136 | });
137 |
138 | suite.add('resolver.resolve without caching using Cloudflare', {
139 | defer: true,
140 | async fn(deferred) {
141 | try {
142 | await resolver.resolve(host, record);
143 | } catch {}
144 |
145 | deferred.resolve();
146 | }
147 | });
148 |
149 | suite.on('cycle', (ev) => {
150 | console.log(String(ev.target));
151 | });
152 |
153 | suite.on('complete', function () {
154 | console.log(
155 | `Fastest without caching is: ${this.filter((bench) =>
156 | bench.name.includes('without caching')
157 | )
158 | .filter('fastest')
159 | .map('name')
160 | .join(', ')}\n`
161 | );
162 | });
163 |
164 | suite.run();
165 |
--------------------------------------------------------------------------------
/.github/workflows/daily-benchmarks.yml:
--------------------------------------------------------------------------------
1 | name: Daily Tangerine Benchmarks
2 |
3 | on:
4 | schedule:
5 | # Run daily at 2:00 AM UTC
6 | - cron: '0 2 * * *'
7 | workflow_dispatch: # Allow manual triggering
8 | push:
9 | branches:
10 | - main
11 | paths:
12 | - 'benchmarks/**'
13 | - '.github/workflows/daily-benchmarks.yml'
14 |
15 | permissions:
16 | contents: write
17 |
18 | jobs:
19 | benchmark:
20 | name: Run Benchmarks on Node ${{ matrix.node-version }}
21 | runs-on: ubuntu-latest
22 | strategy:
23 | matrix:
24 | node-version: [18, 20, 22, 24, 'latest']
25 | fail-fast: false
26 |
27 | steps:
28 | - name: Checkout repository
29 | uses: actions/checkout@v4
30 |
31 | - name: Setup Node.js ${{ matrix.node-version }}
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version: ${{ matrix.node-version }}
35 |
36 | - name: Install dependencies
37 | run: npm install
38 |
39 | - name: Run benchmarks
40 | run: npm run benchmarks -- --json > benchmark_results_node_${{ matrix.node-version }}.json 2>&1 || true
41 | continue-on-error: true
42 |
43 | - name: Run benchmarks (fallback to text output)
44 | run: |
45 | node -e "
46 | const { execSync } = require('child_process');
47 | const fs = require('fs');
48 | const version = process.version;
49 |
50 | const results = {
51 | node_version: version,
52 | platform: process.platform,
53 | arch: process.arch,
54 | timestamp: new Date().toISOString(),
55 | benchmarks: {}
56 | };
57 |
58 | // Run each benchmark and capture output
59 | const benchmarks = ['lookup', 'resolve', 'reverse'];
60 | for (const bench of benchmarks) {
61 | try {
62 | const output = execSync(\`node benchmarks/\${bench}\`, {
63 | encoding: 'utf8',
64 | timeout: 300000
65 | });
66 | results.benchmarks[bench] = output;
67 | } catch (err) {
68 | results.benchmarks[bench] = err.message;
69 | }
70 | }
71 |
72 | fs.writeFileSync(
73 | \`benchmark_results_node_\${version}.json\`,
74 | JSON.stringify(results, null, 2)
75 | );
76 | console.log('Benchmark results saved for Node.js', version);
77 | "
78 | continue-on-error: true
79 |
80 | - name: Upload benchmark results
81 | uses: actions/upload-artifact@v4
82 | with:
83 | name: benchmark-results-node-${{ matrix.node-version }}
84 | path: benchmark_results_node_*.json
85 | if-no-files-found: warn
86 |
87 | consolidate:
88 | name: Consolidate Results and Update README
89 | needs: benchmark
90 | runs-on: ubuntu-latest
91 |
92 | steps:
93 | - name: Checkout repository
94 | uses: actions/checkout@v4
95 | with:
96 | fetch-depth: 0
97 |
98 | - name: Setup Node.js 20
99 | uses: actions/setup-node@v4
100 | with:
101 | node-version: 20
102 |
103 | - name: Download all benchmark results
104 | uses: actions/download-artifact@v4
105 | with:
106 | path: benchmark-artifacts
107 |
108 | - name: Consolidate benchmark results
109 | run: |
110 | # Move all JSON files to root directory
111 | find benchmark-artifacts -name "*.json" -exec cp {} . \;
112 |
113 | # List all result files
114 | ls -la benchmark_results_*.json || echo "No benchmark result files found"
115 |
116 | - name: Install dependencies for update script
117 | run: npm install
118 |
119 | - name: Generate updated README
120 | run: node scripts/update-readme.js
121 |
122 | - name: Configure Git
123 | run: |
124 | git config --local user.email "github-actions[bot]@users.noreply.github.com"
125 | git config --local user.name "github-actions[bot]"
126 |
127 | - name: Commit and push changes
128 | run: |
129 | git add benchmark_results_*.json README.md
130 | if git diff --staged --quiet; then
131 | echo "No changes to commit"
132 | else
133 | git commit -m "chore: update benchmark results [skip ci]"
134 | git push
135 | fi
136 | env:
137 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
138 |
--------------------------------------------------------------------------------
/scripts/update-readme.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Update README.md with latest benchmark results
5 | * This script reads all benchmark_results_*.json files and updates the README
6 | * with a consolidated markdown table showing performance across Node.js versions
7 | */
8 |
9 | const fs = require('node:fs');
10 | const path = require('node:path');
11 | const process = require('node:process');
12 |
13 | const README_PATH = path.join(__dirname, '..', 'README.md');
14 | const RESULTS_DIR = path.join(__dirname, '..');
15 |
16 | // Markers for where to insert benchmark results in README
17 | const START_MARKER = '';
18 | const END_MARKER = '';
19 |
20 | function generateBenchmarkSection(results) {
21 | const versions = Object.keys(results).sort((a, b) => {
22 | // Sort by major version number
23 | const vA = Number.parseInt(a.replace('v', '').split('.')[0], 10);
24 | const vB = Number.parseInt(b.replace('v', '').split('.')[0], 10);
25 | return vA - vB;
26 | });
27 |
28 | let section = `#### Latest Automated Benchmark Results\n\n`;
29 | section += `**Last Updated:** ${new Date().toISOString().split('T')[0]}\n\n`;
30 |
31 | // Create a table showing Node.js version info
32 | section += `| Node Version | Platform | Arch | Timestamp |\n`;
33 | section += `|--------------|----------|------|----------|\n`;
34 |
35 | for (const version of versions) {
36 | const data = results[version];
37 | const timestamp = new Date(data.timestamp).toLocaleDateString('en-US', {
38 | year: 'numeric',
39 | month: 'short',
40 | day: 'numeric'
41 | });
42 | section += `| ${version} | ${data.platform} | ${data.arch} | ${timestamp} |\n`;
43 | }
44 |
45 | section += `\n`;
46 |
47 | // Add benchmark output for each version
48 | section += `\nClick to expand detailed benchmark results
\n\n`;
49 |
50 | for (const version of versions) {
51 | const data = results[version];
52 | section += `##### Node.js ${version}\n\n`;
53 |
54 | if (data.benchmarks) {
55 | for (const [benchName, output] of Object.entries(data.benchmarks)) {
56 | section += `**${benchName}:**\n\n`;
57 | section += '```text\n';
58 | section += String(output).trim();
59 | section += '\n```\n\n';
60 | }
61 | }
62 | }
63 |
64 | section += ` \n`;
65 |
66 | return section;
67 | }
68 |
69 | function main() {
70 | console.log('🔄 Updating README with latest benchmark results...\n');
71 |
72 | // Find all benchmark result files
73 | const files = fs.readdirSync(RESULTS_DIR);
74 | const resultFiles = files
75 | .filter(
76 | (f) => f.startsWith('benchmark_results_node_') && f.endsWith('.json')
77 | )
78 | .sort();
79 |
80 | if (resultFiles.length === 0) {
81 | console.log('ℹ️ No benchmark result files found, skipping update.');
82 | return;
83 | }
84 |
85 | console.log(`📊 Found ${resultFiles.length} benchmark result files:`);
86 | for (const f of resultFiles) console.log(` - ${f}`);
87 | console.log('');
88 |
89 | // Load all benchmark results
90 | const results = {};
91 | for (const file of resultFiles) {
92 | try {
93 | const data = JSON.parse(
94 | fs.readFileSync(path.join(RESULTS_DIR, file), 'utf8')
95 | );
96 | const version = data.node_version;
97 | results[version] = data;
98 | console.log(`✅ Loaded results for Node.js ${version}`);
99 | } catch (err) {
100 | console.error(`❌ Error loading ${file}:`, err.message);
101 | }
102 | }
103 |
104 | if (Object.keys(results).length === 0) {
105 | console.log('ℹ️ No valid benchmark results loaded, skipping update.');
106 | return;
107 | }
108 |
109 | // Generate benchmark results section
110 | const benchmarkSection = generateBenchmarkSection(results);
111 |
112 | // Read current README
113 | let readme = fs.readFileSync(README_PATH, 'utf8');
114 |
115 | // Check if markers exist
116 | if (readme.includes(START_MARKER) && readme.includes(END_MARKER)) {
117 | // Replace content between markers
118 | const beforeMarker = readme.slice(
119 | 0,
120 | readme.indexOf(START_MARKER) + START_MARKER.length
121 | );
122 | const afterMarker = readme.slice(readme.indexOf(END_MARKER));
123 | readme = beforeMarker + '\n\n' + benchmarkSection + '\n\n' + afterMarker;
124 | } else {
125 | console.error('❌ Markers not found in README.md');
126 | console.error(
127 | 'Please add the following markers to README.md where you want benchmark results:'
128 | );
129 | console.error(` ${START_MARKER}`);
130 | console.error(` ${END_MARKER}`);
131 | process.exit(1);
132 | }
133 |
134 | // Write updated README
135 | fs.writeFileSync(README_PATH, readme, 'utf8');
136 |
137 | console.log('\n✅ README.md updated successfully!');
138 | console.log(`📝 Updated: ${README_PATH}`);
139 | }
140 |
141 | try {
142 | main();
143 | } catch (err) {
144 | console.error('❌ Fatal error:', err);
145 | process.exit(1);
146 | }
147 |
--------------------------------------------------------------------------------
/benchmarks/http.js:
--------------------------------------------------------------------------------
1 | const http = require('node:http');
2 | const process = require('node:process');
3 | const Benchmark = require('benchmark');
4 | const axios = require('axios');
5 | const fetch = require('node-fetch');
6 | const fetchMock = require('fetch-mock');
7 | const got = require('got');
8 | const nock = require('nock');
9 | const phin = require('phin');
10 | const request = require('request');
11 | const superagent = require('superagent');
12 | const undici = require('undici');
13 |
14 | const PROTOCOL = process.env.BENCHMARK_PROTOCOL || 'http';
15 | const HOST = process.env.BENCHMARK_HOST || 'test';
16 | const PORT = process.env.BENCHMARK_PORT
17 | ? Number.parseInt(process.env.BENCHMARK_PORT, 10)
18 | : 80;
19 | const PATH = process.env.BENCHMARK_PATH || '/test';
20 | const URL = `${PROTOCOL}://${HOST}:${PORT}${PATH}`;
21 |
22 | axios.defaults.baseURL = `http://${HOST}`;
23 |
24 | if (HOST === 'test') {
25 | const mockAgent = new undici.MockAgent();
26 |
27 | mockAgent
28 | .get(axios.defaults.baseURL)
29 | .intercept({ path: PATH })
30 | .reply(200, 'ok');
31 |
32 | undici.setGlobalDispatcher(mockAgent);
33 |
34 | nock(axios.defaults.baseURL)
35 | .persist()
36 | .post(PATH)
37 | .reply(200, 'ok')
38 | .get(PATH)
39 | .reply(200, 'ok');
40 |
41 | fetchMock.mock(URL, 200);
42 | }
43 |
44 | const suite = new Benchmark.Suite();
45 |
46 | suite.on('start', function (ev) {
47 | console.log(`Started: ${ev.currentTarget.name}`);
48 | });
49 |
50 | suite.add('http.request POST request', {
51 | defer: true,
52 | fn(defer) {
53 | const req = http.request(
54 | { host: HOST, port: PORT, path: PATH, method: 'POST' },
55 | (res) => {
56 | res.resume().on('end', () => defer.resolve());
57 | }
58 | );
59 | req.write('');
60 | req.end();
61 | }
62 | });
63 |
64 | suite.add('http.request GET request', {
65 | defer: true,
66 | fn(defer) {
67 | http
68 | .request({ path: PATH, host: HOST, port: PORT }, (res) => {
69 | res.resume().on('end', () => defer.resolve());
70 | })
71 | .end();
72 | }
73 | });
74 |
75 | suite.add('undici GET request', {
76 | defer: true,
77 | fn(defer) {
78 | undici
79 | .request(URL)
80 | .then(() => defer.resolve())
81 | .catch(() => defer.resolve());
82 | }
83 | });
84 |
85 | suite.add('undici POST request', {
86 | defer: true,
87 | fn(defer) {
88 | undici
89 | .request(URL, { method: 'POST' })
90 | .then(() => defer.resolve())
91 | .catch(() => defer.resolve());
92 | }
93 | });
94 |
95 | suite.add('axios GET request', {
96 | defer: true,
97 | fn(defer) {
98 | axios
99 | .get(PATH)
100 | .then(() => defer.resolve())
101 | .catch(() => defer.resolve());
102 | }
103 | });
104 |
105 | suite.add('axios POST request', {
106 | defer: true,
107 | fn(defer) {
108 | axios
109 | .post(PATH)
110 | .then(() => defer.resolve())
111 | .catch(() => defer.resolve());
112 | }
113 | });
114 |
115 | suite.add('got GET request', {
116 | defer: true,
117 | fn(defer) {
118 | got
119 | .get(URL, { throwHttpErrors: false, retry: 0 })
120 | .then(() => defer.resolve())
121 | .catch(() => defer.resolve());
122 | }
123 | });
124 |
125 | suite.add('got POST request', {
126 | defer: true,
127 | fn(defer) {
128 | got
129 | .post(URL, { throwHttpErrors: false })
130 | .then(() => defer.resolve())
131 | .catch(() => defer.resolve());
132 | }
133 | });
134 |
135 | suite.add('fetch GET request', {
136 | defer: true,
137 | fn(defer) {
138 | fetch(URL).then(() => defer.resolve());
139 | }
140 | });
141 |
142 | suite.add('fetch POST request', {
143 | defer: true,
144 | fn(defer) {
145 | fetch(URL, { method: 'POST' })
146 | .then(() => defer.resolve())
147 | .catch(() => defer.resolve());
148 | }
149 | });
150 |
151 | suite.add('request GET request', {
152 | defer: true,
153 | fn(defer) {
154 | request(URL, () => defer.resolve());
155 | }
156 | });
157 |
158 | suite.add('request POST request', {
159 | defer: true,
160 | fn(defer) {
161 | request.post({ url: URL }, () => defer.resolve());
162 | }
163 | });
164 |
165 | suite.add('superagent GET request', {
166 | defer: true,
167 | fn(defer) {
168 | superagent.get(URL).end(() => defer.resolve());
169 | }
170 | });
171 |
172 | suite.add('superagent POST request', {
173 | defer: true,
174 | fn(defer) {
175 | superagent
176 | .post(URL)
177 | .send()
178 | .end(() => defer.resolve());
179 | }
180 | });
181 |
182 | suite.add('phin GET request', {
183 | defer: true,
184 | fn(defer) {
185 | phin(URL).then(() => defer.resolve());
186 | }
187 | });
188 |
189 | suite.add('phin POST request', {
190 | defer: true,
191 | fn(defer) {
192 | phin({ url: URL, method: 'POST' }).then(() => defer.resolve());
193 | }
194 | });
195 |
196 | suite.on('cycle', function (ev) {
197 | console.log(String(ev.target));
198 | });
199 |
200 | suite.on('complete', function () {
201 | console.log(
202 | 'Fastest is ' + this.filter('fastest').map('name').join(', ') + '\n'
203 | );
204 | });
205 |
206 | suite.run();
207 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | // Type definitions for tangerine
2 | // Project: https://github.com/forwardemail/tangerine
3 | // Definitions by: Forward Email
4 |
5 | /* eslint-disable @typescript-eslint/naming-convention */
6 | /* eslint-disable @typescript-eslint/member-ordering */
7 |
8 | import { Resolver } from 'node:dns/promises';
9 | import type { LookupAddress, LookupOptions } from 'node:dns';
10 |
11 | export type TangerineOptions = {
12 | /**
13 | * Timeout in milliseconds for DNS queries.
14 | * @default 5000
15 | */
16 | timeout?: number;
17 |
18 | /**
19 | * Number of retry attempts for DNS queries.
20 | * @default 4
21 | */
22 | tries?: number;
23 |
24 | /**
25 | * DNS-over-HTTPS servers to use.
26 | * @default new Set(['1.1.1.1', '1.0.0.1'])
27 | */
28 | servers?: Set | string[];
29 |
30 | /**
31 | * Request options passed to the HTTP client.
32 | */
33 | requestOptions?: {
34 | method?: 'GET' | 'POST' | 'get' | 'post';
35 | headers?: Record;
36 | };
37 |
38 | /**
39 | * Protocol to use for DNS-over-HTTPS requests.
40 | * @default 'https'
41 | */
42 | protocol?: 'http' | 'https';
43 |
44 | /**
45 | * DNS result ordering.
46 | * @default 'verbatim' for Node.js >= 17.0.0, 'ipv4first' otherwise
47 | */
48 | dnsOrder?: 'verbatim' | 'ipv4first';
49 |
50 | /**
51 | * Logger instance for debugging.
52 | * Set to `false` to disable logging.
53 | * @default false
54 | */
55 | logger?:
56 | | false
57 | | {
58 | info: (...args: unknown[]) => void;
59 | warn: (...args: unknown[]) => void;
60 | error: (...args: unknown[]) => void;
61 | };
62 |
63 | /**
64 | * ID generator for DNS packets.
65 | * Can be a number or a function that returns a number (sync or async).
66 | * @default 0
67 | */
68 | id?: number | (() => number) | (() => Promise);
69 |
70 | /**
71 | * Concurrency limit for resolveAny queries.
72 | * @default os.cpus().length
73 | */
74 | concurrency?: number;
75 |
76 | /**
77 | * Default IPv4 address for local binding.
78 | * @default '0.0.0.0'
79 | */
80 | ipv4?: string;
81 |
82 | /**
83 | * Default IPv6 address for local binding.
84 | * @default '::0'
85 | */
86 | ipv6?: string;
87 |
88 | /**
89 | * Port for IPv4 local binding.
90 | */
91 | ipv4Port?: number;
92 |
93 | /**
94 | * Port for IPv6 local binding.
95 | */
96 | ipv6Port?: number;
97 |
98 | /**
99 | * Cache instance for storing DNS results.
100 | * Set to `false` to disable caching.
101 | * @default new Map()
102 | */
103 | cache?: Map | false;
104 |
105 | /**
106 | * Default TTL in seconds for cached DNS results.
107 | * @default 300
108 | */
109 | defaultTTLSeconds?: number;
110 |
111 | /**
112 | * Maximum TTL in seconds for cached DNS results.
113 | * @default 86400
114 | */
115 | maxTTLSeconds?: number;
116 |
117 | /**
118 | * Function to generate cache arguments.
119 | */
120 | setCacheArgs?: (
121 | key: string,
122 | result: { expires: number; ttl: number }
123 | ) => unknown[];
124 |
125 | /**
126 | * Whether to return HTTP errors as DNS errors.
127 | * @default false
128 | */
129 | returnHTTPErrors?: boolean;
130 |
131 | /**
132 | * Whether to rotate servers on errors.
133 | * @default true
134 | */
135 | smartRotate?: boolean;
136 |
137 | /**
138 | * Default error message for unsuccessful HTTP responses.
139 | * @default 'Unsuccessful HTTP response'
140 | */
141 | defaultHTTPErrorMessage?: string;
142 | };
143 |
144 | export type DnsRecordType =
145 | | 'A'
146 | | 'A6'
147 | | 'AAAA'
148 | | 'AFSDB'
149 | | 'AMTRELAY'
150 | | 'ANY'
151 | | 'APL'
152 | | 'ATMA'
153 | | 'AVC'
154 | | 'AXFR'
155 | | 'CAA'
156 | | 'CDNSKEY'
157 | | 'CDS'
158 | | 'CERT'
159 | | 'CNAME'
160 | | 'CSYNC'
161 | | 'DHCID'
162 | | 'DLV'
163 | | 'DNAME'
164 | | 'DNSKEY'
165 | | 'DOA'
166 | | 'DS'
167 | | 'EID'
168 | | 'EUI48'
169 | | 'EUI64'
170 | | 'GID'
171 | | 'GPOS'
172 | | 'HINFO'
173 | | 'HIP'
174 | | 'HTTPS'
175 | | 'IPSECKEY'
176 | | 'ISDN'
177 | | 'IXFR'
178 | | 'KEY'
179 | | 'KX'
180 | | 'L32'
181 | | 'L64'
182 | | 'LOC'
183 | | 'LP'
184 | | 'MAILA'
185 | | 'MAILB'
186 | | 'MB'
187 | | 'MD'
188 | | 'MF'
189 | | 'MG'
190 | | 'MINFO'
191 | | 'MR'
192 | | 'MX'
193 | | 'NAPTR'
194 | | 'NID'
195 | | 'NIMLOC'
196 | | 'NINFO'
197 | | 'NS'
198 | | 'NSAP'
199 | | 'NSAP-PTR'
200 | | 'NSEC'
201 | | 'NSEC3'
202 | | 'NSEC3PARAM'
203 | | 'NULL'
204 | | 'NXT'
205 | | 'OPENPGPKEY'
206 | | 'OPT'
207 | | 'PTR'
208 | | 'PX'
209 | | 'RKEY'
210 | | 'RP'
211 | | 'RRSIG'
212 | | 'RT'
213 | | 'Reserved'
214 | | 'SIG'
215 | | 'SINK'
216 | | 'SMIMEA'
217 | | 'SOA'
218 | | 'SPF'
219 | | 'SRV'
220 | | 'SSHFP'
221 | | 'SVCB'
222 | | 'TA'
223 | | 'TALINK'
224 | | 'TKEY'
225 | | 'TLSA'
226 | | 'TSIG'
227 | | 'TXT'
228 | | 'UID'
229 | | 'UINFO'
230 | | 'UNSPEC'
231 | | 'URI'
232 | | 'WKS'
233 | | 'X25'
234 | | 'ZONEMD';
235 |
236 | export type MxRecord = {
237 | priority: number;
238 | exchange: string;
239 | type?: 'MX';
240 | };
241 |
242 | export type NaptrRecord = {
243 | flags: string;
244 | service: string;
245 | regexp: string;
246 | replacement: string;
247 | order: number;
248 | preference: number;
249 | type?: 'NAPTR';
250 | };
251 |
252 | export type SoaRecord = {
253 | nsname: string;
254 | hostmaster: string;
255 | serial: number;
256 | refresh: number;
257 | retry: number;
258 | expire: number;
259 | minttl: number;
260 | type?: 'SOA';
261 | };
262 |
263 | export type SrvRecord = {
264 | priority: number;
265 | weight: number;
266 | port: number;
267 | name: string;
268 | type?: 'SRV';
269 | };
270 |
271 | export type CaaRecord = {
272 | critical: number;
273 | issue?: string;
274 | issuewild?: string;
275 | iodef?: string;
276 | contactemail?: string;
277 | contactphone?: string;
278 | type?: 'CAA';
279 | };
280 |
281 | export type CertRecord = {
282 | certType: number | string;
283 | keyTag: number;
284 | algorithm: number;
285 | certificate: Uint8Array | string;
286 | };
287 |
288 | export type TlsaRecord = {
289 | usage: number;
290 | selector: number;
291 | mtype: number;
292 | matchingType: number;
293 | cert: Uint8Array;
294 | certificate: Uint8Array;
295 | };
296 |
297 | export type HttpsRecord = {
298 | name: string;
299 | ttl: number;
300 | type: 'HTTPS';
301 | priority: number;
302 | target: string;
303 | params: Record;
304 | };
305 |
306 | export type SvcbRecord = {
307 | name: string;
308 | ttl: number;
309 | type: 'SVCB';
310 | priority: number;
311 | target: string;
312 | params: Record;
313 | };
314 |
315 | export type AnyRecord = {
316 | type: string;
317 | [key: string]: unknown;
318 | };
319 |
320 | export type ResolveOptions = {
321 | ttl?: boolean;
322 | };
323 |
324 | export type RecordWithTtl = {
325 | address: string;
326 | ttl: number;
327 | };
328 |
329 | export type SpoofPacket = {
330 | id: number;
331 | type: 'response';
332 | flags: number;
333 | flag_qr: boolean;
334 | opcode: string;
335 | flag_aa: boolean;
336 | flag_tc: boolean;
337 | flag_rd: boolean;
338 | flag_ra: boolean;
339 | flag_z: boolean;
340 | flag_ad: boolean;
341 | flag_cd: boolean;
342 | rcode: string;
343 | questions: Array<{ name: string; type: string; class: string }>;
344 | answers: Array<{
345 | name: string;
346 | type: string;
347 | ttl: number;
348 | class: string;
349 | flush: boolean;
350 | data: unknown;
351 | }>;
352 | authorities: unknown[];
353 | additionals: unknown[];
354 | ttl: number;
355 | expires: number;
356 | };
357 |
358 | export type RequestFunction = (
359 | url: string,
360 | options: Record
361 | ) => Promise<{
362 | statusCode: number;
363 | headers: Record;
364 | body: {
365 | arrayBuffer: () => Promise;
366 | };
367 | }>;
368 |
369 | declare class Tangerine extends Resolver {
370 | /**
371 | * Path to the hosts file.
372 | */
373 | static HOSTFILE: string;
374 |
375 | /**
376 | * Parsed hosts from the hosts file.
377 | */
378 | static HOSTS: Array<{ ip: string; hosts: string[] }>;
379 |
380 | /**
381 | * Set of valid DNS record types.
382 | */
383 | static TYPES: Set;
384 |
385 | /**
386 | * Set of DNS error codes.
387 | */
388 | static CODES: Set;
389 |
390 | /**
391 | * Record types supported by resolveAny.
392 | */
393 | static ANY_TYPES: string[];
394 |
395 | /**
396 | * Record types supported by native DNS.
397 | */
398 | static NATIVE_TYPES: Set;
399 |
400 | /**
401 | * Check if a port number is valid.
402 | */
403 | static isValidPort(port: number): boolean;
404 |
405 | /**
406 | * Get address configuration types based on network interfaces.
407 | */
408 | static getAddrConfigTypes(): 0 | 4 | 6;
409 |
410 | /**
411 | * Get a random integer between min and max (inclusive).
412 | */
413 | static getRandomInt(min: number, max: number): number;
414 |
415 | /**
416 | * Combine multiple errors into a single error.
417 | */
418 | static combineErrors(errors: Error[]): Error;
419 |
420 | /**
421 | * Create a DNS error with the specified code.
422 | */
423 | static createError(
424 | name: string,
425 | rrtype: string,
426 | code: string,
427 | errno?: number
428 | ): Error;
429 |
430 | /**
431 | * Options passed to the constructor.
432 | */
433 | options: TangerineOptions;
434 |
435 | /**
436 | * Set of active abort controllers.
437 | */
438 | abortControllers: Set;
439 |
440 | /**
441 | * Create a new Tangerine DNS resolver instance.
442 | * @param options - Configuration options
443 | * @param request - HTTP request function (default: undici.request)
444 | */
445 | constructor(options?: TangerineOptions, request?: RequestFunction);
446 |
447 | /**
448 | * Set local addresses for DNS queries.
449 | */
450 | setLocalAddress(ipv4?: string, ipv6?: string): void;
451 |
452 | /**
453 | * Resolve a hostname to IP addresses.
454 | */
455 | lookup(
456 | name: string,
457 | options?: LookupOptions
458 | ): Promise;
459 |
460 | /**
461 | * Perform a reverse DNS lookup.
462 | */
463 | lookupService(
464 | address: string,
465 | port: number,
466 | abortController?: AbortController,
467 | purgeCache?: boolean
468 | ): Promise<{ hostname: string; service: string }>;
469 |
470 | /**
471 | * Reverse DNS lookup for an IP address.
472 | */
473 | reverse(
474 | ip: string,
475 | abortController?: AbortController,
476 | purgeCache?: boolean
477 | ): Promise;
478 |
479 | /**
480 | * Resolve IPv4 addresses.
481 | */
482 | resolve4(
483 | name: string,
484 | options?: ResolveOptions,
485 | abortController?: AbortController
486 | ): Promise;
487 |
488 | /**
489 | * Resolve IPv6 addresses.
490 | */
491 | resolve6(
492 | name: string,
493 | options?: ResolveOptions,
494 | abortController?: AbortController
495 | ): Promise;
496 |
497 | /**
498 | * Resolve CAA records.
499 | */
500 | resolveCaa(
501 | name: string,
502 | options?: ResolveOptions,
503 | abortController?: AbortController
504 | ): Promise;
505 |
506 | /**
507 | * Resolve CNAME records.
508 | */
509 | resolveCname(
510 | name: string,
511 | options?: ResolveOptions,
512 | abortController?: AbortController
513 | ): Promise;
514 |
515 | /**
516 | * Resolve MX records.
517 | */
518 | resolveMx(
519 | name: string,
520 | options?: ResolveOptions,
521 | abortController?: AbortController
522 | ): Promise;
523 |
524 | /**
525 | * Resolve NAPTR records.
526 | */
527 | resolveNaptr(
528 | name: string,
529 | options?: ResolveOptions,
530 | abortController?: AbortController
531 | ): Promise;
532 |
533 | /**
534 | * Resolve NS records.
535 | */
536 | resolveNs(
537 | name: string,
538 | options?: ResolveOptions,
539 | abortController?: AbortController
540 | ): Promise;
541 |
542 | /**
543 | * Resolve PTR records.
544 | */
545 | resolvePtr(
546 | name: string,
547 | options?: ResolveOptions,
548 | abortController?: AbortController
549 | ): Promise;
550 |
551 | /**
552 | * Resolve SOA records.
553 | */
554 | resolveSoa(
555 | name: string,
556 | options?: ResolveOptions,
557 | abortController?: AbortController
558 | ): Promise;
559 |
560 | /**
561 | * Resolve SRV records.
562 | */
563 | resolveSrv(
564 | name: string,
565 | options?: ResolveOptions,
566 | abortController?: AbortController
567 | ): Promise;
568 |
569 | /**
570 | * Resolve TXT records.
571 | */
572 | resolveTxt(
573 | name: string,
574 | options?: ResolveOptions,
575 | abortController?: AbortController
576 | ): Promise;
577 |
578 | /**
579 | * Resolve CERT records.
580 | */
581 | resolveCert(
582 | name: string,
583 | options?: ResolveOptions,
584 | abortController?: AbortController
585 | ): Promise;
586 |
587 | /**
588 | * Resolve TLSA records.
589 | */
590 | resolveTlsa(
591 | name: string,
592 | options?: ResolveOptions,
593 | abortController?: AbortController
594 | ): Promise;
595 |
596 | /**
597 | * Get the list of DNS servers.
598 | */
599 | getServers(): string[];
600 |
601 | /**
602 | * Cancel all pending DNS queries.
603 | */
604 | cancel(): void;
605 |
606 | /**
607 | * Resolve any record type.
608 | */
609 | resolveAny(
610 | name: string,
611 | options?: ResolveOptions,
612 | abortController?: AbortController
613 | ): Promise;
614 |
615 | /**
616 | * Set the default result order for DNS queries.
617 | */
618 | setDefaultResultOrder(dnsOrder: 'verbatim' | 'ipv4first'): void;
619 |
620 | /**
621 | * Set the DNS servers to use.
622 | */
623 | setServers(servers: string[]): void;
624 |
625 | /**
626 | * Create a spoofed DNS packet for testing.
627 | * @param name - The hostname
628 | * @param rrtype - The record type
629 | * @param answers - Array of answers
630 | * @param json - Whether to return JSON string (default: false)
631 | * @param expires - Expiration time in milliseconds (default: 300000 = 5 minutes)
632 | */
633 | spoofPacket(
634 | name: string,
635 | rrtype: DnsRecordType,
636 | answers?: unknown[],
637 | json?: boolean,
638 | expires?: number | Date
639 | ): SpoofPacket | string;
640 |
641 | /**
642 | * Resolve DNS records of a specific type.
643 | */
644 | resolve(
645 | name: string,
646 | rrtype?: DnsRecordType,
647 | options?: ResolveOptions,
648 | abortController?: AbortController
649 | ): Promise;
650 | }
651 |
652 | export default Tangerine;
653 | export { Tangerine };
654 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | const dns = require('node:dns');
2 | const fs = require('node:fs');
3 | const process = require('node:process');
4 | const { Buffer } = require('node:buffer');
5 | const { isIP, isIPv4, isIPv6 } = require('node:net');
6 | const isCI = require('is-ci');
7 | const Redis = require('ioredis-mock');
8 | const _ = require('lodash');
9 | const got = require('got');
10 | const sortKeys = require('sort-keys');
11 | const test = require('ava');
12 | const Tangerine = require('..');
13 |
14 | const { Resolver } = dns.promises;
15 |
16 | // Node.js v24+ adds a 'type' property to certain DNS record objects
17 | const NODE_MAJOR_VERSION = Number.parseInt(
18 | process.versions.node.split('.')[0],
19 | 10
20 | );
21 |
22 | //
23 | // NOTE: tests won't work if you're behind a VPN with DNS blackholed
24 | //
25 | test.before(async (t) => {
26 | // echo the output of `/etc/dnsmasq.conf`
27 | try {
28 | t.log('/etc/dnsmasq.conf');
29 | t.log(fs.readFileSync('/etc/dnsmasq.conf'));
30 | } catch (err) {
31 | t.log(err);
32 | }
33 |
34 | // echo the output of `/usr/local/etc/dnsmasq.d/localhost.conf`
35 | try {
36 | t.log('/usr/local/etc/dnsmasq.d/localhost.conf');
37 | t.log(fs.readFileSync('/usr/local/etc/dnsmasq.d/localhost.conf'));
38 | } catch (err) {
39 | t.log(err);
40 | }
41 |
42 | // log the hosts (useful for debugging)
43 | t.log(Tangerine.HOSTFILE);
44 |
45 | // attempt to setServers and perform a DNS lookup
46 | const tangerine = new Tangerine();
47 | const resolver = new Resolver({ timeout: 3000, tries: 1 });
48 | resolver.setServers(tangerine.getServers());
49 |
50 | t.deepEqual(resolver.getServers(), tangerine.getServers());
51 |
52 | try {
53 | t.log('Testing VPN with DNS blackhole');
54 | await resolver.resolve('cloudflare.com', 'A');
55 | } catch (err) {
56 | if (err.code === dns.TIMEOUT) {
57 | t.context.isBlackholed = true;
58 | t.log('VPN with DNS blackholed detected');
59 | } else {
60 | throw err;
61 | }
62 | }
63 | });
64 |
65 | test('exports', async (t) => {
66 | const pkg = await import('../index.js');
67 | const Tangerine = pkg.default;
68 | const tangerine = new Tangerine();
69 | await t.notThrowsAsync(tangerine.resolve('cloudflare.com'));
70 | });
71 |
72 | // new Tangerine(options)
73 | test('instance', (t) => {
74 | const tangerine = new Tangerine();
75 | t.true(tangerine instanceof Resolver);
76 | t.is(tangerine.options.timeout, 5000);
77 | t.is(tangerine.options.tries, 4);
78 | });
79 |
80 | // tangerine.cancel()
81 | test('cancel', (t) => {
82 | const tangerine = new Tangerine();
83 | const abortController = new AbortController();
84 | abortController.signal.addEventListener(
85 | 'abort',
86 | () => {
87 | tangerine.abortControllers.delete(abortController);
88 | },
89 | { once: true }
90 | );
91 | tangerine.abortControllers.add(abortController);
92 | t.is(tangerine.abortControllers.size, 1);
93 | tangerine.cancel();
94 | t.is(tangerine.abortControllers.size, 0);
95 | });
96 |
97 | // tangerine.getServers()
98 | // tangerine.setServers()
99 | test('getServers and setServers', (t) => {
100 | const tangerine = new Tangerine();
101 | const resolver = new Resolver();
102 | resolver.setServers(tangerine.getServers());
103 | t.deepEqual(tangerine.getServers(), resolver.getServers());
104 | });
105 |
106 | test('getServers with [::0] returns accurate response', (t) => {
107 | const servers = ['1.1.1.1', '[::0]'];
108 | const tangerine = new Tangerine();
109 | const resolver = new Resolver();
110 | resolver.setServers(servers);
111 | tangerine.setServers(servers);
112 | t.deepEqual(tangerine.getServers(), resolver.getServers());
113 | });
114 |
115 | test('getServers with IPv6 returns accurate response', (t) => {
116 | const tangerine = new Tangerine();
117 | const resolver = new Resolver();
118 | const servers = ['1.1.1.1', '2001:db8::1:80', '[2001:db8::1]:8080'];
119 | resolver.setServers(servers);
120 | tangerine.setServers(servers);
121 | t.deepEqual(tangerine.getServers(), resolver.getServers());
122 | });
123 |
124 | // eslint-disable-next-line complexity
125 | function compareResults(t, type, r1, r2) {
126 | // t.log('tangerine', r1);
127 | // t.log('resolver', r2);
128 |
129 | if (type === 'TXT') {
130 | if (!_.isError(r1)) r1 = r1.flat();
131 |
132 | if (!_.isError(r2)) r2 = r2.flat();
133 | }
134 |
135 | switch (type) {
136 | //
137 | // for some hosts the DNS is round-robin or geo-location based or health check based
138 | // so the A records for example would not always return the same
139 | //
140 | // e.g. `dig example.com A` -> 4.4.4.4
141 | // e.g. `dig example.com A` -> 3.3.3.3
142 | // e.g. `dig example.com A` -> 3.3.3.3
143 | // e.g. `dig example.com A` -> 7.7.7.7
144 | // e.g. `dig example.com A` -> 4.4.4.4
145 | //
146 | // as you can see, the results are not consistent, so tests cannot be written for that
147 | // so instead we check that all values are IP addresses
148 | //
149 | case 'A':
150 | case 'AAAA': {
151 | // DNS servers may return different results (one returns records, other returns ENODATA)
152 | // This is acceptable due to DNS variability between DoH and native DNS
153 | if (_.isError(r1) && _.isError(r2)) {
154 | // Both errors - both being errors is acceptable
155 | // Error codes may differ (ENOTFOUND vs ENODATA) due to DNS server differences
156 | t.pass('Both returned errors for A/AAAA query');
157 | } else if (_.isError(r1) || _.isError(r2)) {
158 | // One returned results, one returned error - this is acceptable
159 | // Verify the successful one returned valid IP addresses
160 | const successful = _.isError(r1) ? r2 : r1;
161 | const isValid = successful.every((o) => isIPv4(o) || isIPv6(o));
162 | t.true(isValid, 'Successful result should contain valid IP addresses');
163 | } else {
164 | // Both returned results - verify both contain valid IPs
165 | const r1Valid = r1.every((o) => isIPv4(o) || isIPv6(o));
166 | const r2Valid = r2.every((o) => isIPv4(o) || isIPv6(o));
167 | t.true(r1Valid && r2Valid, 'Both should return valid IP addresses');
168 | }
169 |
170 | break;
171 | }
172 |
173 | case 'SOA': {
174 | if (!_.isError(r1) && !_.isError(r2)) {
175 | // ensure object that has the following values for both
176 | // Node.js v24+ adds 'type' property to SOA records
177 | const keys = [
178 | 'nsname',
179 | 'hostmaster',
180 | 'serial',
181 | 'refresh',
182 | 'retry',
183 | 'expire',
184 | 'minttl'
185 | ];
186 | if (NODE_MAJOR_VERSION >= 24) {
187 | keys.push('type');
188 | }
189 |
190 | t.deepEqual(keys.sort(), Object.keys(r1).sort());
191 | t.deepEqual(keys.sort(), Object.keys(r2).sort());
192 | } else if (_.isError(r1) && _.isError(r2)) {
193 | // Both errors - acceptable due to DNS variability
194 | t.pass('Both returned errors for SOA query');
195 | } else {
196 | // One error, one success - acceptable due to DNS variability
197 | t.pass('SOA results differ due to DNS variability');
198 | }
199 |
200 | break;
201 | }
202 |
203 | case 'CAA': {
204 | // CAA records can vary between DNS servers
205 | if (_.isError(r1) && _.isError(r2)) {
206 | // Both errors - acceptable
207 | t.pass('Both returned errors for CAA query');
208 | } else if (_.isError(r1) || _.isError(r2)) {
209 | // One error, one success - acceptable due to DNS variability
210 | t.pass('CAA results differ due to DNS variability');
211 | } else {
212 | // Both returned results - sort and compare
213 | r1 = _.sortBy(
214 | r1,
215 | (o) => `${o.critical}_${o.iodef}_${o.issue}_${o.issuewild}`
216 | );
217 | r2 = _.sortBy(
218 | r2,
219 | (o) => `${o.critical}_${o.iodef}_${o.issue}_${o.issuewild}`
220 | );
221 | t.deepEqual(r1, r2);
222 | }
223 |
224 | break;
225 | }
226 |
227 | case 'MX': {
228 | // MX records can vary between DNS servers
229 | if (_.isError(r1) && _.isError(r2)) {
230 | // Both errors - acceptable
231 | t.pass('Both returned errors for MX query');
232 | } else if (_.isError(r1) || _.isError(r2)) {
233 | // One error, one success - acceptable due to DNS variability
234 | t.pass('MX results differ due to DNS variability');
235 | } else {
236 | // Both returned results - sort and compare
237 | r1 = _.sortBy(r1, (o) => `${o.exchange}_${o.priority}`);
238 | r2 = _.sortBy(r2, (o) => `${o.exchange}_${o.priority}`);
239 | t.deepEqual(r1, r2);
240 | }
241 |
242 | break;
243 | }
244 |
245 | case 'ANY': {
246 | // sometimes ENOTIMP for dns servers
247 | if (_.isError(r2) && r2.code === dns.NOTIMP) {
248 | t.pass(`${dns.NOTIMP} detected for resolver.resolveAny`);
249 | break;
250 | }
251 |
252 | if (_.isError(r1) || _.isError(r2)) {
253 | // For ANY queries, different results are acceptable
254 | // since different DNS servers handle ANY queries differently
255 | // One may return results while the other returns an error
256 | t.pass('ANY query results differ due to DNS variability');
257 | } else {
258 | // r1/r2 = [ { type: 'TXT', value: 'blah' }, ... ] }
259 | //
260 | // NOTE: this isn't yet implemented (we could alternatively check properties for proper types, see below link's "example of the `ret` object")
261 | //
262 | //
263 | // t.log('Comparison not yet implemented');
264 | t.pass();
265 | }
266 |
267 | break;
268 | }
269 |
270 | case 'reverse': {
271 | // Reverse lookups can vary between DNS servers and /etc/hosts
272 | // If both return arrays, check that they have some overlap or both are valid
273 | if (_.isError(r1) && _.isError(r2)) {
274 | // Both errors - compare error codes
275 | t.is(r1.code, r2.code);
276 | } else if (_.isError(r1) || _.isError(r2)) {
277 | // One error, one success - check if the successful one is empty array
278 | // (which is equivalent to ENOTFOUND in some cases)
279 | if (!_.isError(r1) && r1.length === 0 && _.isError(r2)) {
280 | t.pass('Tangerine returned empty array, native returned error');
281 | } else if (!_.isError(r2) && r2.length === 0 && _.isError(r1)) {
282 | t.pass('Native returned empty array, Tangerine returned error');
283 | } else {
284 | // For reverse lookups, different results are acceptable due to DNS variability
285 | t.pass('Reverse lookup results differ due to DNS/hosts variability');
286 | }
287 | } else {
288 | // Both returned arrays - check they're both valid string arrays
289 | const r1Valid = Array.isArray(r1) && r1.every((s) => _.isString(s));
290 | const r2Valid = Array.isArray(r2) && r2.every((s) => _.isString(s));
291 | t.true(r1Valid && r2Valid, 'Both should return valid string arrays');
292 | }
293 |
294 | break;
295 | }
296 |
297 | case 'TXT': {
298 | // TXT records can vary significantly between DNS servers
299 | // If both return results, just verify they're valid TXT records
300 | if (_.isError(r1) && _.isError(r2)) {
301 | // Both errors - acceptable (error codes may differ: ENOTFOUND vs ENODATA)
302 | t.pass('Both returned errors for TXT query');
303 | } else if (_.isError(r1) || _.isError(r2)) {
304 | // One returned results, one returned error - this is acceptable for TXT
305 | // as different DNS servers may have different TXT records cached
306 | t.pass('TXT results differ due to DNS variability');
307 | } else {
308 | // Both returned results - verify they're valid arrays
309 | const r1Valid = Array.isArray(r1);
310 | const r2Valid = Array.isArray(r2);
311 | t.true(r1Valid && r2Valid, 'Both should return valid arrays');
312 | }
313 |
314 | break;
315 | }
316 |
317 | case 'SRV': {
318 | // SRV records can vary between DNS servers
319 | if (_.isError(r1) && _.isError(r2)) {
320 | // Both errors - just verify both are errors (codes may differ)
321 | t.pass('Both returned errors for SRV query');
322 | } else if (_.isError(r1) || _.isError(r2)) {
323 | // One error, one success - acceptable due to DNS variability
324 | t.pass('SRV results differ due to DNS variability');
325 | } else {
326 | // Both returned results - verify structure
327 | const r1Valid =
328 | Array.isArray(r1) &&
329 | r1.every(
330 | (o) =>
331 | typeof o.priority === 'number' &&
332 | typeof o.weight === 'number' &&
333 | typeof o.port === 'number' &&
334 | typeof o.name === 'string'
335 | );
336 | const r2Valid =
337 | Array.isArray(r2) &&
338 | r2.every(
339 | (o) =>
340 | typeof o.priority === 'number' &&
341 | typeof o.weight === 'number' &&
342 | typeof o.port === 'number' &&
343 | typeof o.name === 'string'
344 | );
345 | t.true(r1Valid && r2Valid, 'Both should return valid SRV arrays');
346 | }
347 |
348 | break;
349 | }
350 |
351 | case 'PTR': {
352 | // PTR records can vary between DNS servers
353 | if (_.isError(r1) && _.isError(r2)) {
354 | // Both errors - just verify both are errors
355 | t.pass('Both returned errors for PTR query');
356 | } else if (_.isError(r1) || _.isError(r2)) {
357 | // One error, one success - acceptable due to DNS variability
358 | t.pass('PTR results differ due to DNS variability');
359 | } else {
360 | // Both returned results - verify they're valid string arrays
361 | const r1Valid = Array.isArray(r1) && r1.every((s) => _.isString(s));
362 | const r2Valid = Array.isArray(r2) && r2.every((s) => _.isString(s));
363 | t.true(r1Valid && r2Valid, 'Both should return valid string arrays');
364 | }
365 |
366 | break;
367 | }
368 |
369 | default: {
370 | // Default case handles CNAME, NS, NAPTR, and other record types
371 | // DNS servers may return different results due to variability
372 | if (_.isError(r1) && _.isError(r2)) {
373 | // Both errors - acceptable (error codes may differ: ENOTFOUND vs ENODATA)
374 | t.pass('Both returned errors for query');
375 | } else if (_.isError(r1) || _.isError(r2)) {
376 | // One error, one success - acceptable due to DNS variability
377 | t.pass('Results differ due to DNS variability');
378 | } else {
379 | // Both returned results - compare them
380 | t.deepEqual(
381 | Array.isArray(r1) && r1.every((s) => _.isString(s))
382 | ? r1.sort()
383 | : sortKeys(r1),
384 | Array.isArray(r2) && r2.every((s) => _.isString(s))
385 | ? r2.sort()
386 | : sortKeys(r2)
387 | );
388 | }
389 | }
390 | }
391 | }
392 |
393 | //
394 | // NOTE: need to test all options
395 | //
396 | for (const host of [
397 | 'localhost',
398 | 'localhost.',
399 | 'localhost.foo',
400 | '..localhost',
401 | '.',
402 | '..',
403 | '..a..',
404 | '.aa.',
405 | ',.,.',
406 | ',..',
407 | '.,',
408 | 'localhost..',
409 | 'beep..',
410 | 'beep.com',
411 | 'beep.com..',
412 | 'beep..com..',
413 | 'foo..com',
414 | '..foo.com',
415 | '..foo..com.',
416 | '.foo.com.',
417 | 'foo..localhost',
418 | 'foo..localhost..localhost',
419 | '.localhost',
420 | 'foo.localhost',
421 | 'foo.bar.localhost',
422 | '::1',
423 | '::0',
424 | 'fe00::0',
425 | 'ff00::0',
426 | '192.168.1.1',
427 | '255.255.255.0',
428 | '255.255.255.255',
429 | '127.0.0.1',
430 | '127.0.1.1',
431 | 'forwardemail.net',
432 | 'cloudflare.com',
433 | 'stackoverflow.com',
434 | 'github.com',
435 | 'gmail.com',
436 | 'microsoft.com'
437 | ]) {
438 | // Test setDefaultResultOrder - verify that the result order matches the requested order
439 | // We only test Tangerine's behavior since native DNS may return different results
440 | test(`setDefaultResultOrder with ${host}`, async (t) => {
441 | const tangerine = new Tangerine({ cache: false });
442 | for (const dnsOrder of ['verbatim', 'ipv4first']) {
443 | tangerine.setDefaultResultOrder(dnsOrder);
444 | let results;
445 | try {
446 | // eslint-disable-next-line no-await-in-loop
447 | results = await tangerine.lookup(host, {
448 | all: true
449 | });
450 | } catch (err) {
451 | // If lookup fails (e.g., for invalid hosts), that's acceptable
452 | t.pass(`lookup failed for ${host} with ${dnsOrder}: ${err.code}`);
453 | continue;
454 | }
455 |
456 | // Verify that the results are properly ordered
457 | if (dnsOrder === 'ipv4first' && results.length > 1) {
458 | // Check that IPv4 addresses come before IPv6 addresses
459 | let seenIPv6 = false;
460 | let orderCorrect = true;
461 | for (const result of results) {
462 | if (result.family === 6) {
463 | seenIPv6 = true;
464 | } else if (result.family === 4 && seenIPv6) {
465 | // IPv4 after IPv6 means order is wrong
466 | orderCorrect = false;
467 | break;
468 | }
469 | }
470 |
471 | t.true(
472 | orderCorrect,
473 | `IPv4 addresses should come before IPv6 for ${host} with ipv4first`
474 | );
475 | } else {
476 | // For verbatim or single results, just verify we got valid results
477 | t.true(
478 | results.every((r) => r.family === 4 || r.family === 6),
479 | `All results should have valid family for ${host}`
480 | );
481 | }
482 | }
483 | });
484 |
485 | // tangerine.reverse
486 | test(`reverse("${host}")`, async (t) => {
487 | const tangerine = new Tangerine();
488 | const resolver = new Resolver();
489 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
490 |
491 | let r1;
492 | let r2;
493 | try {
494 | r1 = await tangerine.reverse(host);
495 | } catch (err) {
496 | r1 = err;
497 | }
498 |
499 | try {
500 | r2 = await resolver.reverse(host);
501 | } catch (err) {
502 | r2 = err;
503 | }
504 |
505 | t.log(r1);
506 | t.log(r2);
507 |
508 | compareResults(t, 'reverse', r1, r2);
509 | });
510 |
511 | // tangerine.lookup"${host}"[, options])
512 | //
513 | // TODO: if the local DNS resolver on the server that c-ares communicates with
514 | // is using a wildcard or regex based approach for matching hostnames
515 | // then it won't match in these tests because we only check for /etc/hosts
516 | // (see #compatibility section of README for more insight)
517 | //
518 | if (!isCI || !['.', 'foo.localhost', 'foo.bar.localhost'].includes(host))
519 | test(`lookup("${host}")`, async (t) => {
520 | // returns { address: IP , family: 4 || 6 }
521 | const tangerine = new Tangerine();
522 | let r1;
523 | let r2;
524 | try {
525 | r1 = await tangerine.lookup(host);
526 | } catch (err) {
527 | r1 = err;
528 | }
529 |
530 | try {
531 | r2 = await dns.promises.lookup(host);
532 | } catch (err) {
533 | r2 = err;
534 | }
535 |
536 | t.log(r1);
537 | t.log(r2);
538 |
539 | // Handle errors - errno values can differ between platforms (-3007 vs -3008)
540 | if (_.isError(r1) && _.isError(r2)) {
541 | // Both errors - compare error codes (ignore errno as it varies by platform)
542 | t.is(r1.code, r2.code, 'Error codes should match');
543 | return;
544 | }
545 |
546 | if (_.isError(r1) || _.isError(r2)) {
547 | // One error, one success - acceptable due to DNS variability
548 | t.pass('lookup results differ due to DNS variability');
549 | return;
550 | }
551 |
552 | if (_.isPlainObject(r1)) r1 = [r1];
553 | if (_.isPlainObject(r2)) r2 = [r2];
554 | r1 = r1.every((o) => isIP(o.address) === o.family);
555 | r2 = r2.every((o) => isIP(o.address) === o.family);
556 | t.deepEqual(r1, r2);
557 | });
558 |
559 | // tangerine.resolve"${host}"[, rrtype])
560 | test(`resolve("${host}")`, async (t) => {
561 | const tangerine = new Tangerine();
562 | const resolver = new Resolver();
563 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
564 | let r1;
565 | let r2;
566 | try {
567 | r1 = await tangerine.resolve(host);
568 | } catch (err) {
569 | r1 = err;
570 | }
571 |
572 | try {
573 | r2 = await resolver.resolve(host);
574 | } catch (err) {
575 | r2 = err;
576 | }
577 |
578 | t.log(r1);
579 | t.log(r2);
580 |
581 | // Handle DNS variability - one may return results while other returns error
582 | if (_.isError(r1) && _.isError(r2)) {
583 | // Both errors - acceptable
584 | t.pass('Both returned errors for resolve query');
585 | return;
586 | }
587 |
588 | if (_.isError(r1) || _.isError(r2)) {
589 | // One error, one success - acceptable due to DNS variability
590 | const successful = _.isError(r1) ? r2 : r1;
591 | const isValid = successful.every((o) => isIPv4(o) || isIPv6(o));
592 | t.true(isValid, 'Successful result should contain valid IP addresses');
593 | return;
594 | }
595 |
596 | // see explanation below regarding this under "A" and "AAAA" in switch/case
597 | const r1Valid = r1.every((o) => isIPv4(o) || isIPv6(o));
598 | const r2Valid = r2.every((o) => isIPv4(o) || isIPv6(o));
599 | t.true(r1Valid && r2Valid, 'Both should return valid IP addresses');
600 | });
601 |
602 | for (const type of Tangerine.DNS_TYPES) {
603 | test(`resolve("${host}", "${type}")`, async (t) => {
604 | const tangerine = new Tangerine();
605 | const resolver = new Resolver();
606 |
607 | // mirror DNS servers for accuracy (e.g. SOA)
608 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
609 |
610 | let h = host;
611 | if (type === 'SRV') {
612 | // t.log('switching SRV lookup to _submission._tcp.hostname');
613 | h = `_submission._tcp.${host}`;
614 | }
615 |
616 | let r1;
617 | try {
618 | r1 = await tangerine.resolve(h, type);
619 | } catch (err) {
620 | r1 = err;
621 | }
622 |
623 | let r2;
624 | try {
625 | r2 = await resolver.resolve(h, type);
626 | } catch (err) {
627 | r2 = err;
628 | }
629 |
630 | // if (host === h) t.log(host, type);
631 | // else t.log(host, type, h);
632 | compareResults(t, type, r1, r2);
633 | });
634 | }
635 |
636 | // tangerine.resolve4"${host}"[, options, abortController])
637 | test(`resolve4("${host}")`, async (t) => {
638 | const tangerine = new Tangerine();
639 | const resolver = new Resolver();
640 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
641 | let r1;
642 | try {
643 | r1 = await tangerine.resolve4(host);
644 | } catch (err) {
645 | r1 = err;
646 | }
647 |
648 | let r2;
649 | try {
650 | r2 = await resolver.resolve4(host);
651 | } catch (err) {
652 | r2 = err;
653 | }
654 |
655 | compareResults(t, 'A', r1, r2);
656 | });
657 |
658 | // tangerine.resolve6"${host}"[, options, abortController])
659 | test(`resolve6("${host}")`, async (t) => {
660 | const tangerine = new Tangerine();
661 | const resolver = new Resolver();
662 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
663 | let r1;
664 | try {
665 | r1 = await tangerine.resolve6(host);
666 | } catch (err) {
667 | r1 = err;
668 | }
669 |
670 | let r2;
671 | try {
672 | r2 = await resolver.resolve6(host);
673 | } catch (err) {
674 | r2 = err;
675 | }
676 |
677 | compareResults(t, 'AAAA', r1, r2);
678 | });
679 |
680 | // tangerine.resolveAny"${host}"[, abortController])
681 | if (!isCI)
682 | test(`resolveAny("${host}")`, async (t) => {
683 | const tangerine = new Tangerine();
684 | const resolver = new Resolver();
685 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
686 |
687 | let r1;
688 | try {
689 | r1 = await tangerine.resolveAny(host);
690 | } catch (err) {
691 | r1 = err;
692 | }
693 |
694 | let r2;
695 | try {
696 | r2 = await resolver.resolveAny(host);
697 | } catch (err) {
698 | r2 = err;
699 | }
700 |
701 | compareResults(t, 'ANY', r1, r2);
702 | });
703 |
704 | // tangerine.resolveCaa"${host}"[, abortController]))
705 | test(`resolveCaa("${host}")`, async (t) => {
706 | const tangerine = new Tangerine();
707 | const resolver = new Resolver();
708 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
709 |
710 | let r1;
711 | try {
712 | r1 = await tangerine.resolveCaa(host);
713 | } catch (err) {
714 | r1 = err;
715 | }
716 |
717 | let r2;
718 | try {
719 | r2 = await resolver.resolveCaa(host);
720 | } catch (err) {
721 | r2 = err;
722 | }
723 |
724 | compareResults(t, 'CAA', r1, r2);
725 | });
726 |
727 | // tangerine.resolveCname"${host}"[, abortController]))
728 | test(`resolveCname("${host}")`, async (t) => {
729 | const tangerine = new Tangerine();
730 | const resolver = new Resolver();
731 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
732 |
733 | let r1;
734 | try {
735 | r1 = await tangerine.resolveCname(host);
736 | } catch (err) {
737 | r1 = err;
738 | }
739 |
740 | let r2;
741 | try {
742 | r2 = await resolver.resolveCname(host);
743 | } catch (err) {
744 | r2 = err;
745 | }
746 |
747 | compareResults(t, 'CNAME', r1, r2);
748 | });
749 |
750 | // tangerine.resolveMx"${host}"[, abortController]))
751 | test(`resolveMx("${host}")`, async (t) => {
752 | const tangerine = new Tangerine();
753 | const resolver = new Resolver();
754 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
755 |
756 | let r1;
757 | try {
758 | r1 = await tangerine.resolveMx(host);
759 | } catch (err) {
760 | r1 = err;
761 | }
762 |
763 | let r2;
764 | try {
765 | r2 = await resolver.resolveMx(host);
766 | } catch (err) {
767 | r2 = err;
768 | }
769 |
770 | compareResults(t, 'MX', r1, r2);
771 | });
772 |
773 | // tangerine.resolveNaptr"${host}"[, abortController]))
774 | test(`resolveNaptr("${host}")`, async (t) => {
775 | const tangerine = new Tangerine();
776 | const resolver = new Resolver();
777 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
778 |
779 | let r1;
780 | try {
781 | r1 = await tangerine.resolveNaptr(host);
782 | } catch (err) {
783 | r1 = err;
784 | }
785 |
786 | let r2;
787 | try {
788 | r2 = await resolver.resolveNaptr(host);
789 | } catch (err) {
790 | r2 = err;
791 | }
792 |
793 | compareResults(t, 'NAPTR', r1, r2);
794 | });
795 |
796 | // tangerine.resolveNs"${host}"[, abortController]))
797 | test(`resolveNs("${host}")`, async (t) => {
798 | const tangerine = new Tangerine();
799 | const resolver = new Resolver();
800 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
801 |
802 | let r1;
803 | try {
804 | r1 = await tangerine.resolveNs(host);
805 | } catch (err) {
806 | r1 = err;
807 | }
808 |
809 | let r2;
810 | try {
811 | r2 = await resolver.resolveNs(host);
812 | } catch (err) {
813 | r2 = err;
814 | }
815 |
816 | compareResults(t, 'NS', r1, r2);
817 | });
818 |
819 | // tangerine.resolvePtr"${host}"[, abortController]))
820 | test(`resolvePtr("${host}")`, async (t) => {
821 | const tangerine = new Tangerine();
822 | const resolver = new Resolver();
823 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
824 |
825 | let r1;
826 | try {
827 | r1 = await tangerine.resolvePtr(host);
828 | } catch (err) {
829 | r1 = err;
830 | }
831 |
832 | let r2;
833 | try {
834 | r2 = await resolver.resolvePtr(host);
835 | } catch (err) {
836 | r2 = err;
837 | }
838 |
839 | compareResults(t, 'PTR', r1, r2);
840 | });
841 |
842 | // tangerine.resolveSoa"${host}"[, abortController]))
843 | test(`resolveSoa("${host}")`, async (t) => {
844 | const tangerine = new Tangerine();
845 | const resolver = new Resolver();
846 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
847 |
848 | let r1;
849 | try {
850 | r1 = await tangerine.resolveSoa(host);
851 | } catch (err) {
852 | r1 = err;
853 | }
854 |
855 | let r2;
856 | try {
857 | r2 = await resolver.resolveSoa(host);
858 | } catch (err) {
859 | r2 = err;
860 | }
861 |
862 | compareResults(t, 'SOA', r1, r2);
863 | });
864 |
865 | // tangerine.resolveSrv"${host}"[, abortController]))
866 | test(`resolveSrv("${host}")`, async (t) => {
867 | const tangerine = new Tangerine();
868 | const resolver = new Resolver();
869 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
870 |
871 | let r1;
872 | try {
873 | r1 = await tangerine.resolveSrv(host);
874 | } catch (err) {
875 | r1 = err;
876 | }
877 |
878 | let r2;
879 | try {
880 | r2 = await resolver.resolveSrv(host);
881 | } catch (err) {
882 | r2 = err;
883 | }
884 |
885 | compareResults(t, 'SRV', r1, r2);
886 | });
887 |
888 | // tangerine.resolveTxt"${host}"[, abortController]))
889 | test(`resolveTxt("${host}")`, async (t) => {
890 | const tangerine = new Tangerine();
891 | const resolver = new Resolver();
892 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
893 |
894 | let r1;
895 | try {
896 | r1 = await tangerine.resolveTxt(host);
897 | } catch (err) {
898 | r1 = err;
899 | }
900 |
901 | let r2;
902 | try {
903 | r2 = await resolver.resolveTxt(host);
904 | } catch (err) {
905 | r2 = err;
906 | }
907 |
908 | // ensures buffer decoding cache working
909 | let r3;
910 | try {
911 | r3 = await tangerine.resolveTxt(host);
912 | } catch (err) {
913 | r3 = err;
914 | }
915 |
916 | compareResults(t, 'TXT', r1, r2);
917 | compareResults(t, 'TXT', r1, r3);
918 | compareResults(t, 'TXT', r2, r3);
919 | });
920 | }
921 |
922 | // tangerine.lookupService(address, port)
923 | test('lookupService', async (t) => {
924 | // returns { hostname, service }
925 | // so we can sort by hostname_service
926 | const tangerine = new Tangerine();
927 | const r1 = await tangerine.lookupService('1.1.1.1', 80);
928 | const r2 = await dns.promises.lookupService('1.1.1.1', 80);
929 | t.deepEqual(r1, { hostname: 'one.one.one.one', service: 'http' });
930 | t.deepEqual(r2, { hostname: 'one.one.one.one', service: 'http' });
931 | });
932 |
933 | // tangerine.reverse(ip)
934 | test('reverse', async (t) => {
935 | // returns an array of reversed hostnames from IP address
936 | const tangerine = new Tangerine();
937 | const resolver = new Resolver();
938 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
939 |
940 | let r1;
941 | try {
942 | r1 = await tangerine.reverse('1.1.1.1');
943 | } catch (err) {
944 | r1 = err;
945 | }
946 |
947 | let r2;
948 | try {
949 | r2 = await resolver.reverse('1.1.1.1');
950 | } catch (err) {
951 | r2 = err;
952 | }
953 |
954 | t.deepEqual(r1, ['one.one.one.one']);
955 | t.deepEqual(r2, ['one.one.one.one']);
956 | });
957 |
958 | test('timeout', async (t) => {
959 | const tangerine = new Tangerine({
960 | timeout: 1,
961 | tries: 1
962 | });
963 | const err = await t.throwsAsync(tangerine.resolve('cloudflare.com'));
964 | t.is(err.code, dns.TIMEOUT);
965 | });
966 |
967 | test('supports got HTTP library', async (t) => {
968 | const tangerine = new Tangerine(
969 | {
970 | requestOptions: {
971 | responseType: 'buffer',
972 | decompress: false,
973 | retry: {
974 | limit: 0
975 | }
976 | }
977 | },
978 | got
979 | );
980 | const resolver = new Resolver();
981 | if (!t.context.isBlackholed) resolver.setServers(tangerine.getServers());
982 | const host = 'cloudflare.com';
983 | let r1 = await tangerine.resolve(host);
984 | let r2 = await resolver.resolve(host);
985 | // see explanation below regarding this under "A" and "AAAA" in switch/case
986 | if (!_.isError(r1)) r1 = r1.every((o) => isIPv4(o) || isIPv6(o));
987 | if (!_.isError(r2)) r2 = r2.every((o) => isIPv4(o) || isIPv6(o));
988 | t.deepEqual(r1, r2);
989 | });
990 |
991 | test('creates default cache', (t) => {
992 | const tangerine = new Tangerine();
993 | t.true(tangerine.options.cache instanceof Map);
994 | });
995 |
996 | test('default cache supports ttl', async (t) => {
997 | const tangerine = new Tangerine();
998 | const a = await tangerine.resolve('forwardemail.net');
999 | const b = await tangerine.options.cache.get('a:forwardemail.net');
1000 | compareResults(
1001 | t,
1002 | 'A',
1003 | a,
1004 | b.answers.map((a) => a.data)
1005 | );
1006 | });
1007 |
1008 | test('supports redis cache', async (t) => {
1009 | const cache = new Redis();
1010 |
1011 | //
1012 | Redis.Command.setArgumentTransformer('set', (args) => {
1013 | if (typeof args[1] === 'object') args[1] = JSON.stringify(args[1]);
1014 | return args;
1015 | });
1016 |
1017 | Redis.Command.setReplyTransformer('get', (value) => {
1018 | if (value && typeof value === 'string') {
1019 | try {
1020 | value = JSON.parse(value);
1021 | } catch {}
1022 | }
1023 |
1024 | return value;
1025 | });
1026 |
1027 | const tangerine = new Tangerine({
1028 | cache,
1029 | setCacheArgs(key, result) {
1030 | return ['PX', Math.round(result.ttl * 1000)];
1031 | }
1032 | });
1033 |
1034 | t.true(tangerine.options.cache instanceof Redis);
1035 |
1036 | const a = await tangerine.resolve('forwardemail.net');
1037 | const b = await tangerine.options.cache.get('a:forwardemail.net');
1038 | const c = await cache.get('a:forwardemail.net');
1039 |
1040 | compareResults(
1041 | t,
1042 | 'A',
1043 | a,
1044 | b.answers.map((a) => a.data)
1045 | );
1046 |
1047 | compareResults(
1048 | t,
1049 | 'A',
1050 | b.answers.map((a) => a.data),
1051 | c.answers.map((a) => a.data)
1052 | );
1053 | });
1054 |
1055 | test('supports decoding of cached Buffers', async (t) => {
1056 | const json = `{"id":0,"type":"response","flags":384,"flag_qr":true,"opcode":"QUERY","flag_aa":false,"flag_tc":false,"flag_rd":true,"flag_ra":true,"flag_z":false,"flag_ad":false,"flag_cd":false,"rcode":"NOERROR","questions":[{"name":"forwardemail.net","type":"TXT","class":"IN"}],"answers":[{"name":"forwardemail.net","type":"TXT","ttl":3600,"class":"IN","flush":false,"data":[{"type":"Buffer","data":[104,101,108,108,111,32,119,111,114,108,100,33]}]},{"name":"forwardemail.net","type":"TXT","ttl":3600,"class":"IN","flush":false,"data":[{"type":"Buffer","data":[104,101,108,108,111,32,119,111,114,108,100,33]}]},{"name":"forwardemail.net","type":"TXT","ttl":3600,"class":"IN","flush":false,"data":[{"type":"Buffer","data":[104,101,108,108,111,32,119,111,114,108,100,33]}]},{"name":"forwardemail.net","type":"TXT","ttl":3600,"class":"IN","flush":false,"data":[{"type":"Buffer","data":[104,101,108,108,111,32,119,111,114,108,100,33]}]},{"name":"forwardemail.net","type":"TXT","ttl":3600,"class":"IN","flush":false,"data":[{"type":"Buffer","data":[104,101,108,108,111,32,119,111,114,108,100,33]}]}],"authorities":[],"additionals":[{"name":".","type":"OPT","udpPayloadSize":1232,"extendedRcode":0,"ednsVersion":0,"flags":0,"flag_do":false,"options":[{"code":12,"type":"PADDING","data":{"type":"Buffer","data":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}}]}],"ttl":3600,"expires":${
1057 | Date.now() + 10000
1058 | }}`;
1059 | const cache = new Map();
1060 | const { get } = cache;
1061 | cache.get = function (key) {
1062 | return JSON.parse(get.call(cache, key));
1063 | };
1064 |
1065 | const tangerine = new Tangerine({ cache });
1066 | cache.set('txt:forwardemail.net', json);
1067 | const results = await tangerine.resolveTxt('forwardemail.net');
1068 | t.deepEqual(results, [
1069 | ['hello world!'],
1070 | ['hello world!'],
1071 | ['hello world!'],
1072 | ['hello world!'],
1073 | ['hello world!']
1074 | ]);
1075 | });
1076 |
1077 | //
1078 | test('resolveCert', async (t) => {
1079 | const tangerine = new Tangerine();
1080 |
1081 | let r1;
1082 | try {
1083 | r1 = await tangerine.resolveCert('ett.healthit.gov');
1084 | } catch (err) {
1085 | r1 = err;
1086 | }
1087 |
1088 | // Since the node resolver has no support for resolving CERT
1089 | // records, the standard approach won't work here. So, we lookup
1090 | // a well known address that DOES have a CERT record, then check
1091 | // that the resorts are sensible, since that's the best we can do.
1092 | t.assert(r1.length > 0, "Couldn't resolve CERT record for ett.healthit.gov!");
1093 |
1094 | t.log(r1);
1095 |
1096 | for (const d of r1) {
1097 | t.assert(typeof d === 'object', 'must be an object');
1098 | t.assert(typeof d.name === 'string', 'name missing');
1099 | t.assert(typeof d.ttl === 'number', 'ttl missing');
1100 | t.assert(
1101 | typeof d.certificate_type === 'string',
1102 | 'certificate_type missing'
1103 | );
1104 | t.assert(typeof d.key_tag === 'number', 'key_tag missing');
1105 | t.assert(typeof d.algorithm === 'number', 'algorithm missing');
1106 | t.assert(typeof d.certificate === 'string', 'certificate missing');
1107 | }
1108 | });
1109 |
1110 | // similar edge case as resolveCert above, but for resolveTlsa
1111 | //
1112 | test('resolveTlsa', async (t) => {
1113 | const tangerine = new Tangerine();
1114 |
1115 | let r1;
1116 | try {
1117 | r1 = await tangerine.resolveTlsa('_25._tcp.internet.nl');
1118 | } catch (err) {
1119 | r1 = err;
1120 | }
1121 |
1122 | // TLSA records may not be available due to DNS server variability
1123 | // or the domain may have changed its TLSA records
1124 | if (_.isError(r1)) {
1125 | t.log(
1126 | 'TLSA lookup returned error (acceptable due to DNS variability):',
1127 | r1.code
1128 | );
1129 | t.pass('TLSA lookup returned error - acceptable due to DNS variability');
1130 | return;
1131 | }
1132 |
1133 | t.assert(
1134 | r1.length > 0,
1135 | "Couldn't resolve TLSA record for _25._tcp.internet.nl!"
1136 | );
1137 |
1138 | t.log(r1);
1139 |
1140 | for (const d of r1) {
1141 | t.assert(typeof d === 'object', 'must be an object');
1142 | t.assert(typeof d.name === 'string', 'name missing');
1143 | t.assert(typeof d.ttl === 'number', 'ttl missing');
1144 | t.assert(typeof d.usage === 'number', 'usage missing');
1145 | t.assert(typeof d.selector === 'number', 'selector missing');
1146 | t.assert(typeof d.mtype === 'number', 'mtype missing');
1147 | t.assert(Buffer.isBuffer(d.cert), 'cert must be buffer');
1148 | }
1149 | });
1150 |
1151 | test('spoofPacket with json', async (t) => {
1152 | const cache = new Redis();
1153 | const tangerine = new Tangerine({ cache });
1154 |
1155 | const txt = tangerine.spoofPacket(
1156 | 'forwardemail.net',
1157 | 'TXT',
1158 | [`v=spf1 ip4:127.0.0.1 -all`],
1159 | true
1160 | );
1161 |
1162 | t.deepEqual(_.omit(JSON.parse(txt), ['expires']), {
1163 | id: 0,
1164 | type: 'response',
1165 | flags: 384,
1166 | flag_qr: true,
1167 | opcode: 'QUERY',
1168 | flag_aa: false,
1169 | flag_tc: false,
1170 | flag_rd: true,
1171 | flag_ra: true,
1172 | flag_z: false,
1173 | flag_ad: false,
1174 | flag_cd: false,
1175 | rcode: 'NOERROR',
1176 | questions: [{ name: 'forwardemail.net', type: 'TXT', class: 'IN' }],
1177 | answers: [
1178 | {
1179 | name: 'forwardemail.net',
1180 | type: 'TXT',
1181 | ttl: 300,
1182 | class: 'IN',
1183 | flush: false,
1184 | data: ['v=spf1 ip4:127.0.0.1 -all']
1185 | }
1186 | ],
1187 | authorities: [],
1188 | additionals: [
1189 | {
1190 | name: '.',
1191 | type: 'OPT',
1192 | udpPayloadSize: 1232,
1193 | extendedRcode: 0,
1194 | ednsVersion: 0,
1195 | flags: 0,
1196 | flag_do: false,
1197 | options: [null]
1198 | }
1199 | ],
1200 | ttl: 300
1201 | // expires: 1684087106042
1202 | });
1203 |
1204 | await cache.set('txt:forwardemail.net', txt);
1205 |
1206 | const txtDns = await tangerine.resolveTxt('forwardemail.net');
1207 |
1208 | t.deepEqual(txtDns, [['v=spf1 ip4:127.0.0.1 -all']]);
1209 | });
1210 |
1211 | test('spoofPacket', async (t) => {
1212 | const cache = new Redis();
1213 | const tangerine = new Tangerine({ cache });
1214 |
1215 | const txt = tangerine.spoofPacket('forwardemail.net', 'TXT', [
1216 | `v=spf1 ip4:127.0.0.1 -all`
1217 | ]);
1218 |
1219 | t.deepEqual(txt.answers, [
1220 | {
1221 | name: 'forwardemail.net',
1222 | type: 'TXT',
1223 | ttl: 300,
1224 | class: 'IN',
1225 | flush: false,
1226 | data: ['v=spf1 ip4:127.0.0.1 -all']
1227 | }
1228 | ]);
1229 |
1230 | await cache.set('txt:forwardemail.net', JSON.stringify(txt));
1231 |
1232 | const txtDns = await tangerine.resolveTxt('forwardemail.net');
1233 |
1234 | t.deepEqual(txtDns, [['v=spf1 ip4:127.0.0.1 -all']]);
1235 |
1236 | const mx = tangerine.spoofPacket('forwardemail.net', 'MX', [
1237 | { exchange: 'mx1.forwardemail.net', preference: 0 },
1238 | { exchange: 'mx2.forwardemail.net', preference: 0 }
1239 | ]);
1240 |
1241 | t.deepEqual(mx.answers, [
1242 | {
1243 | name: 'forwardemail.net',
1244 | type: 'MX',
1245 | ttl: 300,
1246 | class: 'IN',
1247 | flush: false,
1248 | data: {
1249 | preference: 0,
1250 | exchange: 'mx1.forwardemail.net'
1251 | }
1252 | },
1253 | {
1254 | name: 'forwardemail.net',
1255 | type: 'MX',
1256 | ttl: 300,
1257 | class: 'IN',
1258 | flush: false,
1259 | data: {
1260 | preference: 0,
1261 | exchange: 'mx2.forwardemail.net'
1262 | }
1263 | }
1264 | ]);
1265 |
1266 | await cache.set('mx:forwardemail.net', JSON.stringify(mx));
1267 |
1268 | const mxDns = await tangerine.resolveMx('forwardemail.net');
1269 |
1270 | // Node.js v24+ adds 'type' property to MX records
1271 | const expectedMx =
1272 | NODE_MAJOR_VERSION >= 24
1273 | ? [
1274 | { exchange: 'mx1.forwardemail.net', priority: 0, type: 'MX' },
1275 | { exchange: 'mx2.forwardemail.net', priority: 0, type: 'MX' }
1276 | ]
1277 | : [
1278 | { exchange: 'mx1.forwardemail.net', priority: 0 },
1279 | { exchange: 'mx2.forwardemail.net', priority: 0 }
1280 | ];
1281 | t.deepEqual(mxDns, expectedMx);
1282 | });
1283 |
1284 | // Test HTTPS record resolution (Issue #10)
1285 | test('resolve HTTPS records for cloudflare.com', async (t) => {
1286 | const tangerine = new Tangerine();
1287 | const result = await tangerine.resolve('cloudflare.com', 'HTTPS');
1288 | t.true(Array.isArray(result), 'HTTPS records should be an array');
1289 | t.true(result.length > 0, 'Should return at least one HTTPS record');
1290 | // Verify the structure of HTTPS records
1291 | for (const record of result) {
1292 | t.is(typeof record.name, 'string', 'HTTPS record should have a name');
1293 | t.is(typeof record.ttl, 'number', 'HTTPS record should have a ttl');
1294 | t.is(record.type, 'HTTPS', 'HTTPS record should have type HTTPS');
1295 | }
1296 | });
1297 |
1298 | // Test SVCB record resolution (Issue #10)
1299 | test('resolve SVCB records', async (t) => {
1300 | const tangerine = new Tangerine();
1301 | // SVCB records are less common, so we test that the method doesn't throw
1302 | // and returns an array (even if empty with ENODATA)
1303 | try {
1304 | const result = await tangerine.resolve('_dns.resolver.arpa', 'SVCB');
1305 | t.true(Array.isArray(result), 'SVCB records should be an array');
1306 | // If we got results, verify the structure
1307 | for (const record of result) {
1308 | t.is(typeof record.name, 'string', 'SVCB record should have a name');
1309 | t.is(typeof record.ttl, 'number', 'SVCB record should have a ttl');
1310 | t.is(record.type, 'SVCB', 'SVCB record should have type SVCB');
1311 | }
1312 | } catch (err) {
1313 | // ENODATA is acceptable if no SVCB records exist
1314 | t.true(
1315 | err.code === dns.NODATA || err.code === dns.NOTFOUND,
1316 | 'Should return ENODATA or ENOTFOUND if no SVCB records exist'
1317 | );
1318 | }
1319 | });
1320 |
1321 | // Test spoofPacket default TTL is 5 minutes (Issue #15)
1322 | test('spoofPacket default expires is 5 minutes (300000ms)', (t) => {
1323 | const tangerine = new Tangerine();
1324 | const before = Date.now();
1325 | const packet = tangerine.spoofPacket('example.com', 'A', ['1.2.3.4']);
1326 | const after = Date.now();
1327 |
1328 | // The expires should be approximately 5 minutes (300000ms) from now
1329 | const expectedMin = before + 300000;
1330 | const expectedMax = after + 300000;
1331 |
1332 | t.true(
1333 | packet.expires >= expectedMin && packet.expires <= expectedMax,
1334 | `spoofPacket expires should be ~5 minutes from now (got ${packet.expires - before}ms)`
1335 | );
1336 | });
1337 |
1338 | // Test spoofPacket with custom expires
1339 | test('spoofPacket with custom expires', (t) => {
1340 | const tangerine = new Tangerine();
1341 | const before = Date.now();
1342 | const customExpires = 60000; // 1 minute
1343 | const packet = tangerine.spoofPacket(
1344 | 'example.com',
1345 | 'A',
1346 | ['1.2.3.4'],
1347 | false,
1348 | customExpires
1349 | );
1350 | const after = Date.now();
1351 |
1352 | const expectedMin = before + customExpires;
1353 | const expectedMax = after + customExpires;
1354 |
1355 | t.true(
1356 | packet.expires >= expectedMin && packet.expires <= expectedMax,
1357 | `spoofPacket expires should be ~1 minute from now with custom expires`
1358 | );
1359 | });
1360 |
1361 | // Test spoofPacket with Date object for expires
1362 | test('spoofPacket with Date object for expires', (t) => {
1363 | const tangerine = new Tangerine();
1364 | const futureDate = new Date(Date.now() + 600000); // 10 minutes from now
1365 | const packet = tangerine.spoofPacket(
1366 | 'example.com',
1367 | 'A',
1368 | ['1.2.3.4'],
1369 | false,
1370 | futureDate
1371 | );
1372 |
1373 | t.is(
1374 | packet.expires,
1375 | futureDate.getTime(),
1376 | 'spoofPacket expires should match the Date object'
1377 | );
1378 | });
1379 |
1380 | // Test TypeScript types are exported correctly
1381 | test('TypeScript types file exists and is valid', (t) => {
1382 | const typesPath = require('node:path').join(__dirname, '..', 'index.d.ts');
1383 | t.true(fs.existsSync(typesPath), 'index.d.ts should exist');
1384 |
1385 | const typesContent = fs.readFileSync(typesPath, 'utf8');
1386 |
1387 | // Verify key type definitions exist
1388 | t.true(
1389 | typesContent.includes('TangerineOptions'),
1390 | 'Should export TangerineOptions type'
1391 | );
1392 | t.true(
1393 | typesContent.includes('type DnsRecordType'),
1394 | 'Should export DnsRecordType type'
1395 | );
1396 | t.true(
1397 | typesContent.includes('HttpsRecord'),
1398 | 'Should export HttpsRecord type'
1399 | );
1400 | t.true(typesContent.includes('SvcbRecord'), 'Should export SvcbRecord type');
1401 | t.true(
1402 | typesContent.includes('class Tangerine extends Resolver'),
1403 | 'Should export Tangerine class extending Resolver'
1404 | );
1405 | t.true(
1406 | typesContent.includes('spoofPacket('),
1407 | 'Should export spoofPacket method'
1408 | );
1409 | t.true(
1410 | typesContent.includes('expires?: number'),
1411 | 'spoofPacket should have expires parameter'
1412 | );
1413 | });
1414 |
1415 | // Test package.json has types field
1416 | test('package.json has types field pointing to index.d.ts', (t) => {
1417 | const pkgPath = require('node:path').join(__dirname, '..', 'package.json');
1418 | const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1419 |
1420 | t.is(pkg.types, 'index.d.ts', 'package.json should have types field');
1421 | t.true(
1422 | pkg.files.includes('index.d.ts'),
1423 | 'package.json files should include index.d.ts'
1424 | );
1425 | });
1426 |
1427 | // Test HTTPS record type is in TYPES set
1428 | test('HTTPS and SVCB are in TYPES set', (t) => {
1429 | t.true(Tangerine.TYPES.has('HTTPS'), 'TYPES should include HTTPS');
1430 | t.true(Tangerine.TYPES.has('SVCB'), 'TYPES should include SVCB');
1431 | // Also verify the UNKNOWN_* types used internally
1432 | t.true(Tangerine.TYPES.has('UNKNOWN_64'), 'TYPES should include UNKNOWN_64');
1433 | t.true(Tangerine.TYPES.has('UNKNOWN_65'), 'TYPES should include UNKNOWN_65');
1434 | });
1435 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |

6 |

7 |

8 |

9 |

10 |

11 |
12 |
13 |
16 |
17 |
20 |
21 |
22 |
23 | ## Table of Contents
24 |
25 | * [Install](#install)
26 | * [Foreword](#foreword)
27 | * [What is this project about](#what-is-this-project-about)
28 | * [Why integrate DNS over HTTPS](#why-integrate-dns-over-https)
29 | * [What does this mean](#what-does-this-mean)
30 | * [What projects were used for inspiration](#what-projects-were-used-for-inspiration)
31 | * [Features](#features)
32 | * [Usage and Examples](#usage-and-examples)
33 | * [ECMAScript modules (ESM)](#ecmascript-modules-esm)
34 | * [CommonJS (CJS)](#commonjs-cjs)
35 | * [API](#api)
36 | * [`new Tangerine(options[, request])`](#new-tangerineoptions-request)
37 | * [`tangerine.cancel()`](#tangerinecancel)
38 | * [`tangerine.getServers()`](#tangerinegetservers)
39 | * [`tangerine.lookup(hostname[, options])`](#tangerinelookuphostname-options)
40 | * [`tangerine.lookupService(address, port[, abortController, purgeCache])`](#tangerinelookupserviceaddress-port-abortcontroller-purgecache)
41 | * [`tangerine.resolve(hostname[, rrtype, options, abortController])`](#tangerineresolvehostname-rrtype-options-abortcontroller)
42 | * [`tangerine.resolve4(hostname[, options, abortController])`](#tangerineresolve4hostname-options-abortcontroller)
43 | * [`tangerine.resolve6(hostname[, options, abortController])`](#tangerineresolve6hostname-options-abortcontroller)
44 | * [`tangerine.resolveAny(hostname[, options, abortController])`](#tangerineresolveanyhostname-options-abortcontroller)
45 | * [`tangerine.resolveCaa(hostname[, options, abortController]))`](#tangerineresolvecaahostname-options-abortcontroller)
46 | * [`tangerine.resolveCname(hostname[, options, abortController]))`](#tangerineresolvecnamehostname-options-abortcontroller)
47 | * [`tangerine.resolveMx(hostname[, options, abortController]))`](#tangerineresolvemxhostname-options-abortcontroller)
48 | * [`tangerine.resolveNaptr(hostname[, options, abortController]))`](#tangerineresolvenaptrhostname-options-abortcontroller)
49 | * [`tangerine.resolveNs(hostname[, options, abortController]))`](#tangerineresolvenshostname-options-abortcontroller)
50 | * [`tangerine.resolvePtr(hostname[, options, abortController]))`](#tangerineresolveptrhostname-options-abortcontroller)
51 | * [`tangerine.resolveSoa(hostname[, options, abortController]))`](#tangerineresolvesoahostname-options-abortcontroller)
52 | * [`tangerine.resolveSrv(hostname[, options, abortController]))`](#tangerineresolvesrvhostname-options-abortcontroller)
53 | * [`tangerine.resolveTxt(hostname[, options, abortController]))`](#tangerineresolvetxthostname-options-abortcontroller)
54 | * [`tangerine.resolveCert(hostname[, options, abortController]))`](#tangerineresolvecerthostname-options-abortcontroller)
55 | * [`tangerine.resolveTlsa(hostname[, options, abortController]))`](#tangerineresolvetlsahostname-options-abortcontroller)
56 | * [`tangerine.reverse(ip[, abortController, purgeCache])`](#tangerinereverseip-abortcontroller-purgecache)
57 | * [`tangerine.setDefaultResultOrder(order)`](#tangerinesetdefaultresultorderorder)
58 | * [`tangerine.setServers(servers)`](#tangerinesetserversservers)
59 | * [`tangerine.spoofPacket(hostname, rrtype, answers[, json, expires = 30000])`](#tangerinespoofpackethostname-rrtype-answers-json-expires--30000)
60 | * [Options](#options)
61 | * [Cache](#cache)
62 | * [Compatibility](#compatibility)
63 | * [Debugging](#debugging)
64 | * [Benchmarks](#benchmarks)
65 | * [Tangerine Benchmarks](#tangerine-benchmarks)
66 | * [HTTP Library Benchmarks](#http-library-benchmarks)
67 | * [Contributors](#contributors)
68 | * [License](#license)
69 |
70 |
71 | ## Install
72 |
73 | ```sh
74 | npm install tangerine undici
75 | ```
76 |
77 | ```diff
78 | -import dns from 'dns';
79 | +import Tangerine from 'tangerine';
80 |
81 | - const resolver = new dns.promises.Resolver();
82 | +const resolver = new Tangerine();
83 | ```
84 |
85 |
86 | ## Foreword
87 |
88 | ### What is this project about
89 |
90 | Our team at [Forward Email](https://forwardemail.net) (100% open-source and privacy-focused email service) needed a better solution for DNS.
91 |
92 | After years of using the Node.js internal DNS module, we ran into these recurring patterns:
93 |
94 | * [Cloudflare](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/) and [Google](https://developers.google.com/speed/public-dns/docs/doh/) now have DNS over HTTPS servers ("DoH") available – and browsers such as Mozilla Firefox now have it [enabled by default](https://support.mozilla.org/en-US/kb/firefox-dns-over-https).
95 | * DNS cache consistency across multiple servers cannot be easily accomplished using packages such as `unbound`, `dnsmasq`, and `bind` – and configuring `/etc/resolv.conf` across multiple Ubuntu versions is not enjoyable (even with Ansible). Maintaining logic at the application layer is much easier from a development, deployment, and maintenance perspective.
96 | * Privacy, security, and caching approaches needed to be constantly scaled, re-written, and re-configured.
97 | * Our development teams would encounter unexpected 75 second delays while making DNS requests (if they were connected to a VPN and forgot they were behind blackholed DNS servers – and attempting to use patterns such as `dns.setServers(['1.1.1.1'])`). The default timeout if you are behind a blackholed DNS server in Node.js is 75 seconds (due to `c-ares` under the hood with `5`, `10`, `20`, and `40` second retry backoff timeout strategy).
98 | * There are **zero existing** DNS over HTTPS ("DoH") Node.js npm packages that:
99 | * Utilize modern open-source software under the MIT license and are currently maintained.
100 | * Once popular packages such as [native-dns](https://github.com/tjfontaine/node-dns/issues/111) and [dnscached](https://github.com/yahoo/dnscache/issues/28) are archived or deprecated.
101 | * [Other packages](https://www.npmjs.com/search?q=dns%20cache) only provide `lookup` functions, have a limited sub-set of methods such as [@zeit/dns-cached-resolver](https://github.com/vercel/dns-cached-resolve), or are unmaintained.
102 | * Act as a 1:1 drop-in replacement for `dns.promises.Resolver` with DNS over HTTPS ("DoH").
103 | * Support caching for multiple backends (with TTL and purge support), retries, smart server rotation, and [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) usage.
104 | * Provide out of the box support for both ECMAScript modules (ESM) **and** CommonJS (CJS) (see discussions [for](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and [against](https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76af7dfcad3)).
105 | * The native Node.js `dns` module does not support caching out of the box – which is a [highly requested feature](https://github.com/nodejs/node/issues/5893) (but belongs in userland).
106 | * Writing tests against DNS-related infrastructure requires either hacky DNS mocking or a DNS server (manipulating cache is much easier).
107 | * **The Node.js community is lacking a high-quality and dummy-proof userland DNS package with sensible defaults.**
108 |
109 | ### Why integrate DNS over HTTPS
110 |
111 | > With DNS over HTTPS (DoH), DNS queries and responses are encrypted and sent via the HTTP or HTTP/2 protocols. DoH ensures that attackers cannot forge or alter DNS traffic. DoH uses port 443, which is the standard HTTPS traffic port, to wrap the DNS query in an HTTPS request. DNS queries and responses are camouflaged within other HTTPS traffic, since it all comes and goes from the same port. – [Cloudflare](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/)
112 |
113 | > DNS over HTTPS (DoH) is a protocol for performing remote [Domain Name System](https://en.wikipedia.org/wiki/Domain_Name_System) (DNS) resolution via the [HTTPS](https://en.wikipedia.org/wiki/HTTPS) protocol. A goal of the method is to increase user privacy and security by preventing eavesdropping and manipulation of DNS data by [man-in-the-middle attacks](https://en.wikipedia.org/wiki/Man-in-the-middle_attacks) by using the HTTPS protocol to [encrypt](https://en.wikipedia.org/wiki/Encrypt) the data between the DoH client and the DoH-based [DNS resolver](https://en.wikipedia.org/wiki/DNS_resolver). – [Wikipedia](https://en.wikipedia.org/wiki/DNS_over_HTTPS)
114 |
115 | ### What does this mean
116 |
117 | [We're](https://forwardemail.net) the only email service provider that is 100% open-source *and* uses DNS over HTTPS ("DoH") throughout their entire infrastructure. We've open-sourced this project – which means you can integrate DNS over HTTPS ("DoH") by simply using :tangerine: Tangerine. Its documentation below includes [Features](#features), [Usage and Examples](#usage-and-examples), [API](#api), [Options](#options), and [Benchmarks](#tangerine-benchmarks).
118 |
119 | ### What projects were used for inspiration
120 |
121 | Thanks to the authors of [dohdec](https://github.com/hildjj/dohdec), [dns-packet](https://github.com/mafintosh/dns-packet), [dns2](https://github.com/song940/node-dns), and [native-dnssec-dns](https://github.com/EduardoRuizM/native-dnssec-dns) – which made this project possible and were used for inspiration.
122 |
123 |
124 | ## Features
125 |
126 | :tangerine: Tangerine is a 1:1 **drop-in replacement with DNS over HTTPS ("DoH")** for [dns.promises.Resolver](https://nodejs.org/api/dns.html#resolveroptions):
127 |
128 | * All options and defaults for `new dns.promises.Resolver()` are available in `new Tangerine()`.
129 | * Instances of `Tangerine` are also instances of `dns.promises.Resolver` as this class `extends` from it. This makes it compatible with [cacheable-lookup](https://github.com/szmarczak/cacheable-lookup).
130 | * HTTP error codes are mapped to DNS error codes (the error `code` and `errno` properties will appear as if they're from `dns` usage). This is a configurable option enabled by default (see `returnHTTPErrors` option).
131 | * If you need callbacks, then use [util.callbackify](https://nodejs.org/api/util.html#utilcallbackifyoriginal) (e.g. `const resolveTxt = callbackify(tangerine.resolveTxt)`).
132 |
133 | We have also added several improvements and new features:
134 |
135 | * Default name servers used have been set to [Cloudflare's](https://1.1.1.1/) (`['1.1.1.1', '1.0.0.1']`) (as opposed to the system default – which is often set to a default which is not privacy-focused or simply forgotten to be set by DevOps teams). You may also want to use [Cloudflare's Malware and Adult Content Blocking](https://blog.cloudflare.com/introducing-1-1-1-1-for-families/) DNS server addresses instead.
136 | * You can pass a custom `servers` option (as opposed to having to invoke `dns.setServers(...)` or `resolver.setServers(...)`).
137 | * `lookup` and `lookupService` methods have been added (these are not in the original `dns.promises.Resolver` instance methods).
138 | * [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) support has been added to all DNS request methods (you can also pass your own).
139 | * The method `cancel()` will signal `"abort"` to all AbortController signals created for existing requests and handle cleanup.
140 | * An `ecsClientSubnet` option has been added to all methods accepting an `options` object for [RFC 7871](https://datatracker.ietf.org/doc/html/rfc7871) client subnet querying (this includes `resolve4` and `resolve6`).
141 | * If you have multiple DNS servers configured (e.g. `tangerine.setServers(['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4'])`) – and if any of these servers have repeated errors, then they will be bumped to the end of the list (e.g. if `1.1.1.1` has errors, then the updated in-memory `Set` for future requests will be `['1.0.0.1', '8.8.8.8', '8.8.4.4', '1.1.1.1']`). This "smart server rotation" behavior can be disabled (see `smartRotate` option) – but it is discouraged, as the original behavior of [c-ares](https://c-ares.org/) does not rotate as such.
142 | * Debug via `NODE_DEBUG=tangerine node app.js` flag (uses [util.debuglog](https://nodejs.org/api/util.html#utildebuglogsection-callback)).
143 | * The method `setLocalAddress()` will parse the IP address and port properly to pass along for use with the agent as `localAddress` and `localPort`. If you require IPv6 addresses with ports, you must encode it as `[IPv6]:PORT` ([similar to RFC 3986](https://serverfault.com/a/205794)).
144 |
145 | All existing syscall values have been preserved:
146 |
147 | * `resolveAny` → `queryAny`
148 | * `resolve4` → `queryA`
149 | * `resolve6` → `queryAaaa`
150 | * `resolveCaa` → `queryCaa`
151 | * `resolveCname` → `queryCname`
152 | * `resolveMx` → `queryMx`
153 | * `resolveNs` → `queryNs`
154 | * `resolveNs` → `queryNs`
155 | * `resolveTxt` → `queryTxt`
156 | * `resolveSrv` → `querySrv`
157 | * `resolvePtr` → `queryPtr`
158 | * `resolveNaptr` → `queryNaptr`
159 | * `resolveSoa` → `querySoa`
160 | * `reverse` → `getHostByAddr`
161 |
162 |
163 | ## Usage and Examples
164 |
165 | ### ECMAScript modules (ESM)
166 |
167 | ```js
168 | // app.mjs
169 |
170 | import Tangerine from 'tangerine';
171 |
172 | const tangerine = new Tangerine();
173 | // or `const resolver = new Tangerine()`
174 |
175 | tangerine.resolve('forwardemail.net').then(console.log);
176 | ```
177 |
178 | ### CommonJS (CJS)
179 |
180 | ```js
181 | // app.js
182 |
183 | const Tangerine = require('tangerine');
184 |
185 | const tangerine = new Tangerine();
186 | // or `const resolver = new Tangerine()`
187 |
188 | tangerine.resolve('forwardemail.net').then(console.log);
189 | ```
190 |
191 |
192 | ## API
193 |
194 | ### `new Tangerine(options[, request])`
195 |
196 | * The `request` argument is a `Function` that defaults to [undici.request](https://undici.nodejs.org/#/?id=undicirequesturl-options-promise).
197 | * This is an HTTP library request async or Promise returning function to be used for making requests.
198 |
199 | * You could alternatively use [got](https://github.com/sindresorhus/got) or any other HTTP library of your choice that accepts `fn(url, options)`. However, we suggest to stick with the default of `undici` due to these [benchmark tests](http-library-benchmarks).
200 |
201 | ```js
202 | const tangerine = new Tangerine(
203 | {
204 | requestOptions: {
205 | responseType: 'buffer',
206 | decompress: false,
207 | retry: {
208 | limit: 0
209 | }
210 | }
211 | },
212 | got
213 | );
214 | ```
215 |
216 | * It should return an object with `body`, `headers`, and either a `status` or `statusCode` property.
217 |
218 | * The `body` property returned should be either a `Buffer` or `Stream`.
219 |
220 | * Specify default request options based off the library under `requestOptions` below
221 | * Instance methods of [dns.promises.Resolver](https://nodejs.org/api/dns.html) are mirrored to :tangerine: Tangerine.
222 | * Resolver methods accept an optional `abortController` argument, which is an instance of [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). Note that :tangerine: Tangerine manages `AbortController` usage internally – so you most likely won't need to pass your own (see [index.js](https://github.com/forwardemail/nodejs-dns-over-https-tangerine/blob/main/index.js) for more insight).
223 | * Resolver methods that accept `options` argument also accept an optional `options.purgeCache` option.
224 | * Resolver methods support a `purgeCache` option as either `options.purgeCache` (Boolean) via `options` argument or `purgeCache` (Boolean) argument – see [API](#api) and [Cache](#cache) for more insight.
225 | * If set to `true`, then the result will be re-queried and re-cached – see [Cache](#cache) documentation for more insight.
226 | * Instances of `new Tangerine()` are instances of `dns.promises.Resolver` via `class Tangerine extends dns.promises.Resolver { ... }` (namely for compatibility with projects such as [cacheable-lookup](https://github.com/szmarczak/cacheable-lookup)).
227 | * See the complete list of [Options](#options) below.
228 | * Any `rrtype` from the list at is supported (unlike the native Node.js DNS module which only supports a limited set).
229 |
230 | ### `tangerine.cancel()`
231 |
232 | ### `tangerine.getServers()`
233 |
234 | ### `tangerine.lookup(hostname[, options])`
235 |
236 | ### `tangerine.lookupService(address, port[, abortController, purgeCache])`
237 |
238 | ### `tangerine.resolve(hostname[, rrtype, options, abortController])`
239 |
240 | ### `tangerine.resolve4(hostname[, options, abortController])`
241 |
242 | Tangerine supports a new `ecsSubnet` property in the `options` Object argument.
243 |
244 | ### `tangerine.resolve6(hostname[, options, abortController])`
245 |
246 | Tangerine supports a new `ecsSubnet` property in the `options` Object argument.
247 |
248 | ### `tangerine.resolveAny(hostname[, options, abortController])`
249 |
250 | ### `tangerine.resolveCaa(hostname[, options, abortController]))`
251 |
252 | ### `tangerine.resolveCname(hostname[, options, abortController]))`
253 |
254 | ### `tangerine.resolveMx(hostname[, options, abortController]))`
255 |
256 | ### `tangerine.resolveNaptr(hostname[, options, abortController]))`
257 |
258 | ### `tangerine.resolveNs(hostname[, options, abortController]))`
259 |
260 | ### `tangerine.resolvePtr(hostname[, options, abortController]))`
261 |
262 | ### `tangerine.resolveSoa(hostname[, options, abortController]))`
263 |
264 | ### `tangerine.resolveSrv(hostname[, options, abortController]))`
265 |
266 | ### `tangerine.resolveTxt(hostname[, options, abortController]))`
267 |
268 | ### `tangerine.resolveCert(hostname[, options, abortController]))`
269 |
270 | This function returns a Promise that resolves with an Array with parsed values from results:
271 |
272 | ```js
273 | [
274 | {
275 | algorithm: 0,
276 | certificate: 'MIIEoTCCA4mgAwIBAgICAacwDQYJKoZIhvcNAQELBQAwgY0xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNRDEOMAwGA1UEBwwFQm95ZHMxEzARBgNVBAoMCkRyYWplciBMTEMxIjAgBgNVBAMMGWludGVybWVkaWF0ZS5oZWFsdGhpdC5nb3YxKDAmBgkqhkiG9w0BCQEWGWludGVybWVkaWF0ZS5oZWFsdGhpdC5nb3YwHhcNMTgwOTI1MTgyNDIzWhcNMjgwOTIyMTgyNDIzWjB7MQswCQYDVQQGEwJVUzELMAkGA1UECAwCTUQxDjAMBgNVBAcMBUJveWRzMRMwEQYDVQQKDApEcmFqZXIgTExDMRkwFwYDVQQDDBBldHQuaGVhbHRoaXQuZ292MR8wHQYJKoZIhvcNAQkBFhBldHQuaGVhbHRoaXQuZ292MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxaA2MIuaqpvP2Id85KIhUVA6zlj+CgZh/3prgJ1q4leP3T5F1tSSgrQ/WYTFglEwN7FJx4yJ324NaKncaMPDBIg3IUgC3Q5nrPUbIJAUgM5+67pXnGgt6s9bQelEsTdbyA/JlLC7Hsv184mqo0yrueC9NJEea4/yTV51G9S4jLjnKhr0XUTw0Fb/PFNL9ZwaEdFgQfUaE1maleazKGDyLLuEGvpXsRNs1Ju/kdHkOUVLf741Cq8qLlqOKN2v5jQkUdFUKHbYIF5KXt4ToV9mvxTaz6Mps1UbS+a73Xr+VqmBqmEQnXA5DZ7ucikzv9DLokDwtmPzhdqye2msgDpw0QIDAQABo4IBGjCCARYwCQYDVR0TBAIwADAbBgNVHREEFDASghBldHQuaGVhbHRoaXQuZ292MB0GA1UdDgQWBBQ6E22jc99mm+WraUj93IvQcw6JHDAfBgNVHSMEGDAWgBRfW20fzencvG+Attm1rcvQV+3rOTALBgNVHQ8EBAMCBaAwSQYDVR0fBEIwQDA+oDygOoY4aHR0cDovL2NhLmRpcmVjdGNhLm9yZy9jcmwvaW50ZXJtZWRpYXRlLmhlYWx0aGl0Lmdvdi5jcmwwVAYIKwYBBQUHAQEESDBGMEQGCCsGAQUFBzAChjhodHRwOi8vY2EuZGlyZWN0Y2Eub3JnL2FpYS9pbnRlcm1lZGlhdGUuaGVhbHRoaXQuZ292LmRlcjANBgkqhkiG9w0BAQsFAAOCAQEAhCASLubdxWp+XzXO4a8zMgWOMpjft+ilIy2ROVKOKslbB7lKx0NR7chrTPxCmK+YTL2ttLaTpOniw/vTGrZgeFPyXzJCNtpnx8fFipPE18OAlKMc2nyy7RfUscf28UAEmFo2cEJfpsZjyynkBsTnQ5rQVNgM7TbXXfboxwWwhg4HnWIcmlTs2YM1a9v+idK6LSfX9y/Nvhf9pl0DQflc9ym4z/XCq87erCce+11kxH1+36N6rRqeiHVBYnoYIGMH690r4cgE8cW5B4eK7kaD3iCbmpChO0gZSa5Lex49WLXeFfM+ukd9y3AB00KMZcsUV5bCgwShH053ZQa+FMON8w==',
277 | certificate_type: 'PKIX',
278 | key_tag: 0,
279 | name: 'ett.healthit.gov',
280 | ttl: 19045,
281 | },
282 | ]
283 | ```
284 |
285 | This mirrors output from .
286 |
287 | ### `tangerine.resolveTlsa(hostname[, options, abortController]))`
288 |
289 | This method was added for DANE and TLSA support. See this [excellent article](https://www.mailhardener.com/kb/dane), [index.js](https://github.com/forwardemail/nodejs-dns-over-https-tangerine/blob/main/index.js), and for more insight.
290 |
291 | This function returns a Promise that resolves with an Array with parsed values from results:
292 |
293 | ```js
294 | [
295 | {
296 | cert: Buffer @Uint8Array [
297 | e1ae9c3d e848ece1 ba72e0d9 91ae4d0d 9ec547c6 bad1ddda b9d6beb0 a7e0e0d8
298 | ],
299 | mtype: 1,
300 | name: 'proloprod.mail._dane.internet.nl',
301 | selector: 1,
302 | ttl: 622,
303 | usage: 2,
304 | },
305 | {
306 | cert: Buffer @Uint8Array [
307 | d6fea64d 4e68caea b7cbb2e0 f905d7f3 ca3308b1 2fd88c5b 469f08ad 7e05c7c7
308 | ],
309 | mtype: 1,
310 | name: 'proloprod.mail._dane.internet.nl',
311 | selector: 1,
312 | ttl: 622,
313 | usage: 3,
314 | },
315 | ]
316 | ```
317 |
318 | This mirrors output from .
319 |
320 | ### `tangerine.reverse(ip[, abortController, purgeCache])`
321 |
322 | ### `tangerine.setDefaultResultOrder(order)`
323 |
324 | ### `tangerine.setServers(servers)`
325 |
326 | ### `tangerine.spoofPacket(hostname, rrtype, answers[, json, expires = 30000])`
327 |
328 | This method is useful for writing tests to spoof DNS packets in-memory.
329 |
330 | The `rrtype` must be either `"TXT"` or `"MX"`, and `answers` must be an Array of DNS resource record answers.
331 |
332 | If you pass `json` as `true`, then value returned will be converted to JSON via `JSON.stringify`.
333 |
334 | The last argument `expires` can either be a `Date` or `Number`. This is the value used for calculating the DNS packet expiration. If it is a `Number`, then the `expires` value will be `Date.now() + expires`. The default value is `30000`, which means it will expire in 30 seconds.
335 |
336 | For example, if you want to spoof TXT and MX records:
337 |
338 | ```js
339 | const Redis = require('ioredis-mock');
340 | const Tangerine = require('tangerine');
341 | const ip = require('ip');
342 |
343 | const cache = new Redis();
344 | const tangerine = new Tangerine({ cache });
345 |
346 | const obj = {};
347 |
348 | obj['txt:forwardmail.net'] = tangerine.spoofPacket('forwardmail.net', 'TXT', [
349 | `v=spf1 ip4:${ip.address()} -all`
350 | ]);
351 |
352 | obj['mx:forwardemail.net'] = tangerine.spoofPacket('forwardemail.net', 'MX', [
353 | { exchange: 'mx1.forwardemail.net', preference: 0 },
354 | { exchange: 'mx2.forwardemail.net', preference: 0 }
355 | ]);
356 |
357 | await cache.mset(obj);
358 |
359 | //
360 | // NOTE: spoofed values are used below (this means no DNS query performed)
361 | //
362 |
363 | const txt = await tangerine.resolveTxt('forwardemail.net');
364 | console.log('txt', txt);
365 |
366 | const mx = await tangerine.resolveMx('forwardemail.net');
367 | console.log('mx', mx);
368 | ```
369 |
370 | **Pull requests are welcome to add support for other `rrtype` values for this method.**
371 |
372 |
373 | ## Options
374 |
375 | Similar to the `options` argument from `new dns.promises.Resolver(options)` invocation – :tangerine: Tangerine also has its own options with default `dns` behavior mirrored. See [index.js](https://github.com/forwardemail/nodejs-dns-over-https-tangerine/blob/main/index.js) for more insight into how these options work.
376 |
377 | | Property | Type | Default Value | Description |
378 | | ------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
379 | | `timeout` | `Number` | `5000` | Number of milliseconds for requests to timeout. |
380 | | `tries` | `Number` | `4` | Number of tries per `server` in `servers` to attempt. |
381 | | `servers` | `Set` or `Array` | `new Set(['1.1.1.1', '1.0.0.1'])` | A Set or Array of [RFC 5952](https://tools.ietf.org/html/rfc5952#section-6) formatted addresses for DNS queries (matches default Node.js dns module behavior). Duplicates will be removed as this is converted to a `Set` internally. Defaults to Cloudflare's of `1.1.1.1` and `1.0.0.1`. If an `Array` is passed, then it will be converted to a `Set`. |
382 | | `requestOptions` | `Object` | Defaults to an Object with `requestOptions.method` and `requestOptions.headers` properties and values below | Default options to pass to [undici](https://github.com/nodejs/undici) (or your custom HTTP library function passed as `request`). |
383 | | `requestOptions.method` | `String` | Defaults to `"GET"` (must be either `"GET"` or `"POST"`, case-insensitive depending on library you use). | Default HTTP method to use for DNS over HTTP ("DoH") requests. |
384 | | `requestOptions.headers` | `Object` | Defaults to `{ 'content-type': 'application/dns-message', 'user-agent': pkg.name + "/" + pkg.version, accept: 'application/dns-message' }`. | Default HTTP headers to use for DNS over HTTP ("DoH") requests. |
385 | | `protocol` | `String` | Defaults to `"https"`. | Default HTTP protocol to use for DNS over HTTPS ("DoH") requests. |
386 | | `dnsOrder` | `String` | Defaults to `"verbatim"` for Node.js v18.0.0+ and `"ipv4first"` for older versions. | Sets the default result order of `lookup` invocations (see [dns.setDefaultResultOrder](https://nodejs.org/api/dns.html#dnssetdefaultresultorderorder) for more insight). |
387 | | `logger` | `Object` | `false` | This is the default logger. We recommend using [Cabin](https://github.com/cabinjs) instead of using `console` as your default logger. Set this value to `false` to disable logging entirely (uses noop function). |
388 | | `id` | `Number` or `Function` | `0` | Default `id` to be passed for DNS packet creation. This could alternatively be a synchronous or asynchronous function that returns a `Number` (e.g. `id: () => Tangerine.getRandomInt(1, 65534)`). |
389 | | `concurrency` | `Number` | `os.cpus().length` | Default concurrency to use for `resolveAny` lookup via [p-map](https://github.com/sindresorhus/p-map). The default value is the number of CPU's available to the system using the Node.js `os` module [os.cpus()](https://nodejs.org/api/os.html#oscpus) method. |
390 | | `ipv4` | `String` | `"0.0.0.0"` | Default IPv4 address to use for HTTP agent `localAddress` if DNS `server` was an IPv4 address. |
391 | | `ipv6` | `String` | `"::0"` | Default IPv6 address to use for HTTP agent `localAddress` if DNS `server` was an IPv6 address. |
392 | | `ipv4Port` | `Number` | `undefined` | Default port to use for HTTP agent `localPort` if DNS `server` was an IPv4 address. |
393 | | `ipv6Port` | `Number` | `undefined` | Default port to use for HTTP agent `localPort` if DNS `server` was an IPv6 address. |
394 | | `cache` | `Map`, `Boolean`, or custom cache implementation with `get` and `set` methods | `new Map()` | Set this to `false` in order to disable caching. By default or if you pass `cache: true`, it will use a new `Map` instance for caching. See [Cache](#cache) documentation and the options `defaultTTLSeconds`, `maxTTLSeconds`, and `setCacheArgs` below. |
395 | | `defaultTTLSeconds` | `Number` (seconds) | `300` | The default number of seconds to use for storing results in cache (defaults to [Cloudflare's recommendation](https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/) of 300 seconds – 5 minutes). |
396 | | `maxTTLSeconds` | `Number` (seconds) | `86400` | The maximum number of seconds to use for storing results in cache (defaults to [Cloudflare's recommendation](https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/) of 86,400 seconds – 24 hours – 1 day). |
397 | | `setCacheArgs` | `Function` | `(key, result) => []` | This is a helper function used for cache store providers such as [ioredis](https://github.com/luin/ioredis) or [lru-cache](https://github.com/isaacs/node-lru-cache) which support more than two arguments to `cache.set()` function. See [Cache](#cache) documentation below for more insight and examples into how this works. You may want to set this to something such as `(key, result) => [ 'PX', Math.round(result.ttl * 1000) ]` if you are using `ioredis`. |
398 | | `returnHTTPErrors` | `Boolean` | `false` | Whether to return HTTP errors instead of mapping them to corresponding DNS errors. |
399 | | `smartRotate` | `Boolean` | `true` | Whether to do smart server rotation if servers fail. |
400 | | `defaultHTTPErrorMessage` | `String` | `"Unsuccessful HTTP response"` | Default fallback message if `statusCode` returned from HTTP request was not found in [http.STATUS_CODES](https://nodejs.org/api/http.html#httpstatus_codes). |
401 |
402 |
403 | ## Cache
404 |
405 | :tangerine: Tangerine supports custom cache implementations, such as with [ioredis](https://github.com/luin/ioredis) or any other cache store that has a Map-like implementation with `set(key, value)` and `get(key)` methods. If your cache implementation allows a third argument to `set()`, such as `set(key, value, ttl)` or `set(key, value, { maxAge })`, then you must set the `setCacheArgs` option respectively (see below examples). A third argument with TTL argument support is optional as it is already built-in to :tangerine: Tangerine out of the box (cached results store their TTL and expiration time on the objects themselves – view source code for insight).
406 |
407 | ```sh
408 | npm install tangerine undici ioredis
409 | ```
410 |
411 | ```js
412 | // app.js
413 |
414 | const Redis = require('ioredis');
415 | const Tangerine = require('tangerine');
416 |
417 | //
418 | Redis.Command.setArgumentTransformer('set', (args) => {
419 | if (typeof args[1] === 'object') args[1] = JSON.stringify(args[1]);
420 | return args;
421 | });
422 |
423 | Redis.Command.setReplyTransformer('get', (value) => {
424 | if (value && typeof value === 'string') {
425 | try {
426 | value = JSON.parse(value);
427 | } catch {}
428 | }
429 |
430 | return value;
431 | });
432 |
433 | const cache = new Redis();
434 | const tangerine = new Tangerine({
435 | cache,
436 | setCacheArgs(key, result) {
437 | return ['PX', Math.round(result.ttl * 1000)];
438 | }
439 | });
440 |
441 | (async () => {
442 | console.time('without cache');
443 | await tangerine.resolve('forwardemail.net'); // <-- cached
444 | console.timeEnd('without cache');
445 |
446 | console.time('with cache');
447 | await tangerine.resolve('forwardemail.net'); // <-- uses cached value
448 | console.timeEnd('with cache');
449 | })();
450 | ```
451 |
452 | ```sh
453 | ❯ node app
454 | without cache: 98.25ms
455 | with cache: 0.091ms
456 | ```
457 |
458 | You can also force the cache to be purged and reset to a new value:
459 |
460 | ```js
461 | await tangerine.resolve('forwardemail.net'); // cached
462 | await tangerine.resolve('forwardemail.net'); // uses cached value
463 | await tangerine.resolve('forwardemail.net'); // uses cached value
464 | await tangerine.resolve('forwardemail.net', { purgeCache: true }); // re-cached
465 | await tangerine.resolve('forwardemail.net'); // uses cached value
466 | await tangerine.resolve('forwardemail.net'); // uses cached value
467 | ```
468 |
469 | This purge cache feature is useful for DNS records that have recently changed and have had their caches purged at the relevant DNS provider (e.g. [Cloudflare's Purge Cache tool](https://1.1.1.1/purge-cache/)).
470 |
471 |
472 | ## Compatibility
473 |
474 | > \[!NOTE]
475 | > **Node.js v24+ DNS Record Type Property**
476 | >
477 | > Starting with Node.js v24, the native DNS resolver adds a `type` property to certain DNS record objects (MX, CAA, SRV, SOA, and NAPTR records). Tangerine automatically includes this property when running on Node.js v24+ to maintain 1:1 compatibility with the native `dns` module. For example:
478 | >
479 | > ```js
480 | > // Node.js v22 and earlier
481 | > { exchange: 'smtp.google.com', priority: 10 }
482 | >
483 | > // Node.js v24+
484 | > { exchange: 'smtp.google.com', priority: 10, type: 'MX' }
485 | > ```
486 |
487 | The only known compatibility issue is for locally running DNS servers that have wildcard DNS matching.
488 |
489 | If you are using `dnsmasq` with a wildcard match on "localhost" to "127.0.0.1", then the results may vary. For example, if your `dnsmasq` configuration has `address=/localhost/127.0.0.1`, then any match of `localhost` will resolve to `127.0.0.1`. This means that `dns.promises.lookup('foo.localhost')` will return `127.0.0.1` – however with :tangerine: Tangerine it will not return a value.
490 |
491 | The reason is because :tangerine: Tangerine only looks at either `/etc/hosts` (macOS/Linux) and `C:/Windows/System32/drivers/etc/hosts` (Windows). It does not lookup BIND, dnsmasq, or other configurations running locally. We would welcome a PR to resolve this (see `isCI` usage in test folder) – however it is a non-issue, as the workaround is to simply append a new line to the hostfile of `127.0.0.1 foo.localhost`.
492 |
493 |
494 | ## Debugging
495 |
496 | If you run into issues while using :tangerine: Tangerine, then these recommendations may help:
497 |
498 | * Set `NODE_DEBUG=tangerine` environment variable flag when you start your app:
499 |
500 | ```sh
501 | NODE_DEBUG=tangerine node app.js
502 | ```
503 |
504 | * Pass a verbose logger as the `logger` option, e.g. `logger: console` (see [Options](#options) above).
505 |
506 | * Assuming you are not allergic, try eating a [nutritious](https://en.wikipedia.org/wiki/Tangerine#Nutrition) :tangerine: tangerine.
507 |
508 |
509 | ## Benchmarks
510 |
511 | Contributors can run benchmarks locally by cloning the repository, installing dependencies, and running the benchmarks script:
512 |
513 | ```sh
514 | git clone https://github.com/forwardemail/nodejs-dns-over-https-tangerine.git
515 | cd tangerine
516 | npm install
517 | npm run benchmarks
518 | ```
519 |
520 | You can also specify optional custom environment variables to test against real-world or locally running servers (instead of using mocked in-memory servers) for the [HTTP Library Benchmarks](#http-library-benchmarks):
521 |
522 | ```sh
523 | BENCHMARK_PROTOCOL="http" BENCHMARK_HOST="127.0.0.1" BENCHMARK_PORT="4000" BENCHMARK_PATH="/v1/test" node benchmarks/http
524 | ```
525 |
526 | ### Tangerine Benchmarks
527 |
528 | We have written extensive benchmarks to show that :tangerine: Tangerine is as fast as the native Node.js DNS module (with the exception of the `lookup` command). Note that performance is opinionated – since rate limiting plays a factor dependent on the DNS servers you are using and since caching is most likely going to takeover.
529 |
530 | ---
531 |
532 |
533 |
534 | #### Latest Automated Benchmark Results
535 |
536 | **Last Updated:** 2025-12-25
537 |
538 | | Node Version | Platform | Arch | Timestamp |
539 | |--------------|----------|------|----------|
540 | | v18.20.8 | linux | x64 | Dec 21, 2025 |
541 | | v20.19.6 | linux | x64 | Dec 25, 2025 |
542 | | v22.21.1 | linux | x64 | Dec 21, 2025 |
543 | | v24.12.0 | linux | x64 | Dec 21, 2025 |
544 | | v25.2.1 | linux | x64 | Dec 21, 2025 |
545 |
546 |
547 | Click to expand detailed benchmark results
548 |
549 | ##### Node.js v18.20.8
550 |
551 | **lookup:**
552 |
553 | ```text
554 | Started: lookup
555 | tangerine.lookup POST with caching using Cloudflare x 757 ops/sec ±195.51% (88 runs sampled)
556 | tangerine.lookup POST without caching using Cloudflare x 120 ops/sec ±1.43% (81 runs sampled)
557 | tangerine.lookup GET with caching using Cloudflare x 287,666 ops/sec ±1.59% (87 runs sampled)
558 | tangerine.lookup GET without caching using Cloudflare x 114 ops/sec ±1.25% (78 runs sampled)
559 | dns.promises.lookup with caching using Cloudflare x 8,803,764 ops/sec ±0.56% (87 runs sampled)
560 | dns.promises.lookup without caching using Cloudflare x 3,214 ops/sec ±0.63% (84 runs sampled)
561 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
562 | ```
563 |
564 | **resolve:**
565 |
566 | ```text
567 | Started: resolve
568 | tangerine.resolve POST with caching using Cloudflare x 953 ops/sec ±195.82% (88 runs sampled)
569 | tangerine.resolve POST without caching using Cloudflare x 116 ops/sec ±1.16% (80 runs sampled)
570 | tangerine.resolve GET with caching using Cloudflare x 1,004,568 ops/sec ±0.26% (87 runs sampled)
571 | tangerine.resolve GET without caching using Cloudflare x 120 ops/sec ±0.99% (81 runs sampled)
572 | tangerine.resolve POST with caching using Google x 1,001,702 ops/sec ±0.27% (88 runs sampled)
573 | tangerine.resolve POST without caching using Google x 126 ops/sec ±1.83% (85 runs sampled)
574 | tangerine.resolve GET with caching using Google x 998,942 ops/sec ±0.34% (89 runs sampled)
575 | tangerine.resolve GET without caching using Google x 116 ops/sec ±3.46% (79 runs sampled)
576 | resolver.resolve with caching using Cloudflare x 6,830,596 ops/sec ±1.01% (86 runs sampled)
577 | resolver.resolve without caching using Cloudflare x 5.39 ops/sec ±235.57% (7 runs sampled)
578 | Fastest without caching is: tangerine.resolve POST without caching using Google
579 | ```
580 |
581 | **reverse:**
582 |
583 | ```text
584 | Started: reverse
585 | tangerine.reverse GET with caching x 628 ops/sec ±195.51% (84 runs sampled)
586 | tangerine.reverse GET without caching x 118 ops/sec ±1.12% (80 runs sampled)
587 | resolver.reverse with caching x 0.10 ops/sec ±0.02% (5 runs sampled)
588 | resolver.reverse without caching x 0.11 ops/sec ±30.81% (5 runs sampled)
589 | dns.promises.reverse with caching x 4.56 ops/sec ±196.00% (82 runs sampled)
590 | dns.promises.reverse without caching x 145 ops/sec ±1.36% (86 runs sampled)
591 | Fastest without caching is: dns.promises.reverse without caching
592 | ```
593 |
594 | ##### Node.js v20.19.6
595 |
596 | **lookup:**
597 |
598 | ```text
599 | Started: lookup
600 | tangerine.lookup POST with caching using Cloudflare x 717 ops/sec ±195.56% (86 runs sampled)
601 | tangerine.lookup POST without caching using Cloudflare x 127 ops/sec ±1.19% (85 runs sampled)
602 | tangerine.lookup GET with caching using Cloudflare x 300,110 ops/sec ±0.27% (90 runs sampled)
603 | tangerine.lookup GET without caching using Cloudflare x 124 ops/sec ±1.14% (84 runs sampled)
604 | dns.promises.lookup with caching using Cloudflare x 9,245,446 ops/sec ±0.55% (88 runs sampled)
605 | dns.promises.lookup without caching using Cloudflare x 3,172 ops/sec ±0.71% (86 runs sampled)
606 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
607 | ```
608 |
609 | **resolve:**
610 |
611 | ```text
612 | Started: resolve
613 | tangerine.resolve POST with caching using Cloudflare x 715 ops/sec ±195.87% (88 runs sampled)
614 | tangerine.resolve POST without caching using Cloudflare x 128 ops/sec ±0.60% (86 runs sampled)
615 | tangerine.resolve GET with caching using Cloudflare x 1,071,910 ops/sec ±0.26% (89 runs sampled)
616 | tangerine.resolve GET without caching using Cloudflare x 131 ops/sec ±0.42% (87 runs sampled)
617 | tangerine.resolve POST with caching using Google x 1,014,907 ops/sec ±0.32% (89 runs sampled)
618 | tangerine.resolve POST without caching using Google x 117 ops/sec ±0.76% (80 runs sampled)
619 | tangerine.resolve GET with caching using Google x 1,015,562 ops/sec ±0.25% (89 runs sampled)
620 | tangerine.resolve GET without caching using Google x 119 ops/sec ±5.40% (82 runs sampled)
621 | resolver.resolve with caching using Cloudflare x 3.15 ops/sec ±115.10% (85 runs sampled)
622 | resolver.resolve without caching using Cloudflare x 10.56 ops/sec ±148.53% (79 runs sampled)
623 | Fastest without caching is: tangerine.resolve GET without caching using Cloudflare
624 | ```
625 |
626 | **reverse:**
627 |
628 | ```text
629 | Started: reverse
630 | tangerine.reverse GET with caching x 872 ops/sec ±195.46% (89 runs sampled)
631 | tangerine.reverse GET without caching x 128 ops/sec ±0.59% (86 runs sampled)
632 | resolver.reverse with caching x 8,239,464 ops/sec ±1.02% (88 runs sampled)
633 | resolver.reverse without caching x 154 ops/sec ±0.42% (90 runs sampled)
634 | dns.promises.reverse with caching x 8,349,302 ops/sec ±0.43% (90 runs sampled)
635 | dns.promises.reverse without caching x 23.17 ops/sec ±166.35% (85 runs sampled)
636 | Fastest without caching is: resolver.reverse without caching, dns.promises.reverse without caching
637 | ```
638 |
639 | ##### Node.js v22.21.1
640 |
641 | **lookup:**
642 |
643 | ```text
644 | Started: lookup
645 | tangerine.lookup POST with caching using Cloudflare x 330,006 ops/sec ±7.57% (90 runs sampled)
646 | tangerine.lookup POST without caching using Cloudflare x 287 ops/sec ±1.96% (84 runs sampled)
647 | tangerine.lookup GET with caching using Cloudflare x 324,567 ops/sec ±0.28% (89 runs sampled)
648 | tangerine.lookup GET without caching using Cloudflare x 311 ops/sec ±2.03% (79 runs sampled)
649 | dns.promises.lookup with caching using Cloudflare x 9,729,406 ops/sec ±0.59% (87 runs sampled)
650 | dns.promises.lookup without caching using Cloudflare x 3,169 ops/sec ±0.81% (87 runs sampled)
651 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
652 | ```
653 |
654 | **resolve:**
655 |
656 | ```text
657 | Started: resolve
658 | tangerine.resolve POST with caching using Cloudflare x 1,150,690 ops/sec ±0.43% (90 runs sampled)
659 | tangerine.resolve POST without caching using Cloudflare x 284 ops/sec ±1.36% (82 runs sampled)
660 | tangerine.resolve GET with caching using Cloudflare x 1,123,967 ops/sec ±0.24% (89 runs sampled)
661 | tangerine.resolve GET without caching using Cloudflare x 335 ops/sec ±1.95% (80 runs sampled)
662 | tangerine.resolve POST with caching using Google x 1,125,905 ops/sec ±0.21% (89 runs sampled)
663 | tangerine.resolve POST without caching using Google x 318 ops/sec ±11.57% (77 runs sampled)
664 | tangerine.resolve GET with caching using Google x 1,128,787 ops/sec ±0.22% (89 runs sampled)
665 | tangerine.resolve GET without caching using Google x 462 ops/sec ±5.05% (80 runs sampled)
666 | resolver.resolve with caching using Cloudflare x 8,204,435 ops/sec ±0.57% (86 runs sampled)
667 | resolver.resolve without caching using Cloudflare x 55.98 ops/sec ±172.36% (78 runs sampled)
668 | Fastest without caching is: tangerine.resolve GET without caching using Google
669 | ```
670 |
671 | **reverse:**
672 |
673 | ```text
674 | spawnSync /bin/sh ETIMEDOUT
675 | ```
676 |
677 | ##### Node.js v24.12.0
678 |
679 | **lookup:**
680 |
681 | ```text
682 | Started: lookup
683 | tangerine.lookup POST with caching using Cloudflare x 1,775 ops/sec ±194.98% (90 runs sampled)
684 | tangerine.lookup POST without caching using Cloudflare x 295 ops/sec ±10.41% (81 runs sampled)
685 | tangerine.lookup GET with caching using Cloudflare x 328,666 ops/sec ±0.25% (90 runs sampled)
686 | tangerine.lookup GET without caching using Cloudflare x 318 ops/sec ±2.96% (80 runs sampled)
687 | dns.promises.lookup with caching using Cloudflare x 10,219,888 ops/sec ±0.98% (84 runs sampled)
688 | dns.promises.lookup without caching using Cloudflare x 3,280 ops/sec ±0.70% (84 runs sampled)
689 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
690 | ```
691 |
692 | **resolve:**
693 |
694 | ```text
695 | Started: resolve
696 | tangerine.resolve POST with caching using Cloudflare x 1,164,729 ops/sec ±0.27% (90 runs sampled)
697 | tangerine.resolve POST without caching using Cloudflare x 316 ops/sec ±1.55% (82 runs sampled)
698 | tangerine.resolve GET with caching using Cloudflare x 1,135,170 ops/sec ±0.25% (90 runs sampled)
699 | tangerine.resolve GET without caching using Cloudflare x 355 ops/sec ±1.42% (83 runs sampled)
700 | tangerine.resolve POST with caching using Google x 1,120,904 ops/sec ±0.27% (90 runs sampled)
701 | tangerine.resolve POST without caching using Google x 427 ops/sec ±6.35% (78 runs sampled)
702 | tangerine.resolve GET with caching using Google x 1,104,301 ops/sec ±0.50% (90 runs sampled)
703 | tangerine.resolve GET without caching using Google x 418 ops/sec ±2.35% (79 runs sampled)
704 | resolver.resolve with caching using Cloudflare x 8,667,172 ops/sec ±0.64% (87 runs sampled)
705 | resolver.resolve without caching using Cloudflare x 0.14 ops/sec ±85.32% (5 runs sampled)
706 | Fastest without caching is: tangerine.resolve GET without caching using Google
707 | ```
708 |
709 | **reverse:**
710 |
711 | ```text
712 | spawnSync /bin/sh ETIMEDOUT
713 | ```
714 |
715 | ##### Node.js v25.2.1
716 |
717 | **lookup:**
718 |
719 | ```text
720 | Started: lookup
721 | tangerine.lookup POST with caching using Cloudflare x 1,504 ops/sec ±195.19% (89 runs sampled)
722 | tangerine.lookup POST without caching using Cloudflare x 118 ops/sec ±2.46% (81 runs sampled)
723 | tangerine.lookup GET with caching using Cloudflare x 341,247 ops/sec ±0.36% (90 runs sampled)
724 | tangerine.lookup GET without caching using Cloudflare x 119 ops/sec ±6.76% (84 runs sampled)
725 | dns.promises.lookup with caching using Cloudflare x 10,273,047 ops/sec ±1.94% (84 runs sampled)
726 | dns.promises.lookup without caching using Cloudflare x 3,255 ops/sec ±1.11% (86 runs sampled)
727 | Fastest without caching is: dns.promises.lookup without caching using Cloudflare
728 | ```
729 |
730 | **resolve:**
731 |
732 | ```text
733 | Started: resolve
734 | tangerine.resolve POST with caching using Cloudflare x 1,200,168 ops/sec ±1.70% (90 runs sampled)
735 | tangerine.resolve POST without caching using Cloudflare x 132 ops/sec ±0.46% (88 runs sampled)
736 | tangerine.resolve GET with caching using Cloudflare x 1,179,037 ops/sec ±0.31% (89 runs sampled)
737 | tangerine.resolve GET without caching using Cloudflare x 120 ops/sec ±0.89% (81 runs sampled)
738 | tangerine.resolve POST with caching using Google x 1,159,414 ops/sec ±2.50% (89 runs sampled)
739 | tangerine.resolve POST without caching using Google x 122 ops/sec ±3.58% (83 runs sampled)
740 | tangerine.resolve GET with caching using Google x 1,166,324 ops/sec ±3.01% (88 runs sampled)
741 | tangerine.resolve GET without caching using Google x 120 ops/sec ±0.48% (81 runs sampled)
742 | resolver.resolve with caching using Cloudflare x 8,890,562 ops/sec ±2.49% (82 runs sampled)
743 | resolver.resolve without caching using Cloudflare x 147 ops/sec ±1.08% (86 runs sampled)
744 | Fastest without caching is: resolver.resolve without caching using Cloudflare
745 | ```
746 |
747 | **reverse:**
748 |
749 | ```text
750 | Started: reverse
751 | tangerine.reverse GET with caching x 342,190 ops/sec ±8.40% (90 runs sampled)
752 | tangerine.reverse GET without caching x 128 ops/sec ±0.83% (86 runs sampled)
753 | resolver.reverse with caching x 9,145,218 ops/sec ±0.55% (85 runs sampled)
754 | resolver.reverse without caching x 155 ops/sec ±0.63% (82 runs sampled)
755 | dns.promises.reverse with caching x 9,157,969 ops/sec ±0.45% (89 runs sampled)
756 | dns.promises.reverse without caching x 5.11 ops/sec ±189.52% (76 runs sampled)
757 | Fastest without caching is: resolver.reverse without caching, dns.promises.reverse without caching
758 | ```
759 |
760 |
761 |
762 |
763 |
764 |
765 | ---
766 |
767 | The benchmarks above are automatically updated daily via `.github/workflows/daily-benchmarks.yml`.
768 |
769 | You can also [run the benchmarks yourself](#benchmarks).
770 |
771 | ---
772 |
773 | Also see this [write-up](https://samknows.com/blog/dns-over-https-performance) on UDP-based DNS versus DNS over HTTPS ("DoH") benchmarks.
774 |
775 | **Speed could be increased** by switching to use [undici streams](https://undici.nodejs.org/#/?id=undicistreamurl-options-factory-promise) and [getStream.buffer](https://github.com/sindresorhus/get-stream) (pull request is welcome).
776 |
777 | ### HTTP Library Benchmarks
778 |
779 | Originally we wrote this library using [got](https://github.com/sindresorhus/got) – however after running benchmarks and learning of [how performant](https://github.com/sindresorhus/got/issues/1419) undici is, we weren't happy – and we rewrote it with [undici](https://github.com/nodejs/undici). Here are test results from the latest versions of all HTTP libraries against our real-world API (both client and server running locally):
780 |
781 | > Node v18.14.2 on MacBook Air M1 16GB (using real-world API server):
782 |
783 | ```sh
784 | node --version
785 | v18.14.2
786 |
787 | > BENCHMARK_HOST="127.0.0.1" BENCHMARK_PORT="4000" BENCHMARK_PATH="/v1/test" node benchmarks/http
788 | http.request POST request x 765 ops/sec ±9.83% (72 runs sampled)
789 | http.request GET request x 1,000 ops/sec ±3.88% (85 runs sampled)
790 | undici GET request x 2,740 ops/sec ±5.92% (78 runs sampled)
791 | undici POST request x 1,247 ops/sec ±0.61% (88 runs sampled)
792 | axios GET request x 792 ops/sec ±7.78% (76 runs sampled)
793 | axios POST request x 717 ops/sec ±13.85% (69 runs sampled)
794 | got GET request x 1,234 ops/sec ±21.10% (67 runs sampled)
795 | got POST request x 113 ops/sec ±168.45% (37 runs sampled)
796 | fetch GET request x 977 ops/sec ±38.12% (51 runs sampled)
797 | fetch POST request x 708 ops/sec ±23.64% (65 runs sampled)
798 | request GET request x 1,152 ops/sec ±40.48% (49 runs sampled)
799 | request POST request x 947 ops/sec ±1.35% (86 runs sampled)
800 | superagent GET request x 148 ops/sec ±139.32% (31 runs sampled)
801 | superagent POST request x 571 ops/sec ±40.14% (54 runs sampled)
802 | phin GET request x 252 ops/sec ±158.51% (50 runs sampled)
803 | phin POST request x 714 ops/sec ±17.39% (62 runs sampled)
804 | Fastest is undici GET request
805 | ```
806 |
807 |
808 | ## Contributors
809 |
810 | | Name | Website |
811 | | ----------------- | -------------------------- |
812 | | **Forward Email** | |
813 |
814 |
815 | ## License
816 |
817 | [MIT](LICENSE) © [Forward Email](https://forwardemail.net)
818 |
819 |
820 | ##
821 |
822 |
823 |
--------------------------------------------------------------------------------