├── 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 | Tangerine 3 |

4 |
5 | build status 6 | code style 7 | styled with prettier 8 | made with lass 9 | license 10 | npm downloads 11 |
12 |
13 |
14 | 🍊 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). 15 |
16 |
17 |
18 | ⚡ AS FAST AS native Node.js dns! 🚀 • Supports Node v18+ with ESM/CJS • Made for Forward Email. 19 |
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 | --------------------------------------------------------------------------------