tangerine 1.5.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +211 -120
  2. package/index.js +374 -172
  3. package/package.json +2 -3
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  </div>
16
16
  <hr />
17
17
  <div align="center">
18
- ⚡ <a href="#tangerine-benchmarks"><i><u><strong>AS FAST AS</strong></u></i></a> native <a href="https://nodejs.org/api/dns.html" target="_blank">Node.js <code>dns</code></a>! 🚀 &bull; Supports Node v16+ with ESM/CJS &bull; Made for <a href="https://forwardemail.net" target="_blank"><strong>Forward Email</strong></a>.
18
+ ⚡ <a href="#tangerine-benchmarks"><i><u><strong>AS FAST AS</strong></u></i></a> native <a href="https://nodejs.org/api/dns.html" target="_blank">Node.js <code>dns</code></a>! 🚀 &bull; Supports Node v17+ with ESM/CJS &bull; Made for <a href="https://forwardemail.net" target="_blank"><strong>Forward Email</strong></a>.
19
19
  </div>
20
20
  <hr />
21
21
 
@@ -471,6 +471,19 @@ This purge cache feature is useful for DNS records that have recently changed an
471
471
 
472
472
  ## Compatibility
473
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
+
474
487
  The only known compatibility issue is for locally running DNS servers that have wildcard DNS matching.
475
488
 
476
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.
@@ -514,170 +527,248 @@ BENCHMARK_PROTOCOL="http" BENCHMARK_HOST="127.0.0.1" BENCHMARK_PORT="4000" BENCH
514
527
 
515
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.
516
529
 
517
- The latest benchmark results are viewable on GitHub under this repository's [GitHub CI actions logs](https://github.com/forwardemail/nodejs-dns-over-https-tangerine/actions?query=event%3Apush):
530
+ ---
518
531
 
519
- > [Node 16 on ubuntu-latest](https://github.com/forwardemail/nodejs-dns-over-https-tangerine/actions/runs/4297805550/jobs/7491228635#step:6:1)
532
+ <!-- BENCHMARK_RESULTS_START -->
520
533
 
521
- ```diff
522
- node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse
534
+ #### Latest Automated Benchmark Results
535
+
536
+ **Last Updated:** 2025-12-21
537
+
538
+ | Node Version | Platform | Arch | Timestamp |
539
+ | ------------ | -------- | ---- | ------------ |
540
+ | v18.20.8 | linux | x64 | Dec 21, 2025 |
541
+ | v20.19.6 | linux | x64 | Dec 21, 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 |
523
545
 
546
+ <details>
547
+ <summary>Click to expand detailed benchmark results</summary>
548
+
549
+ ##### Node.js v18.20.8
550
+
551
+ **lookup:**
552
+
553
+ ```
524
554
  Started: lookup
525
- tangerine.lookup POST with caching using Cloudflare x 735 ops/sec ±195.35% (88 runs sampled)
526
- tangerine.lookup POST without caching using Cloudflare x 142 ops/sec ±0.58% (84 runs sampled)
527
- tangerine.lookup GET with caching using Cloudflare x 222,397 ops/sec ±0.52% (88 runs sampled)
528
- +tangerine.lookup GET without caching using Cloudflare x 142 ops/sec ±0.46% (83 runs sampled)
529
- dns.promises.lookup with caching using Cloudflare x 6,169,417 ops/sec ±1.67% (84 runs sampled)
530
- -dns.promises.lookup without caching using Cloudflare x 4,186 ops/sec ±0.58% (89 runs sampled)
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)
531
561
  Fastest without caching is: dns.promises.lookup without caching using Cloudflare
562
+ ```
563
+
564
+ **resolve:**
532
565
 
566
+ ```
533
567
  Started: resolve
534
- tangerine.resolve POST with caching using Cloudflare x 951 ops/sec ±195.84% (87 runs sampled)
535
- tangerine.resolve POST without caching using Cloudflare x 135 ops/sec ±1.27% (79 runs sampled)
536
- tangerine.resolve GET with caching using Cloudflare x 1,134,724 ops/sec ±0.27% (87 runs sampled)
537
- +tangerine.resolve GET without caching using Cloudflare x 135 ops/sec ±1.34% (81 runs sampled)
538
- tangerine.resolve POST with caching using Google x 1,103,189 ops/sec ±0.44% (86 runs sampled)
539
- tangerine.resolve POST without caching using Google x 55.76 ops/sec ±3.57% (80 runs sampled)
540
- tangerine.resolve GET with caching using Google x 1,140,499 ops/sec ±0.32% (87 runs sampled)
541
- tangerine.resolve GET without caching using Google x 70.51 ops/sec ±0.93% (84 runs sampled)
542
- resolver.resolve with caching using Cloudflare x 4,790,171 ops/sec ±0.43% (87 runs sampled)
543
- -resolver.resolve without caching using Cloudflare x 158 ops/sec ±1.26% (83 runs sampled)
544
- Fastest without caching is: resolver.resolve without caching using Cloudflare
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
+ ```
545
580
 
581
+ **reverse:**
582
+
583
+ ```
546
584
  Started: reverse
547
- tangerine.reverse GET with caching x 771 ops/sec ±195.37% (85 runs sampled)
548
- +tangerine.reverse GET without caching x 135 ops/sec ±0.74% (81 runs sampled)
549
- resolver.reverse with caching x 5,353,130 ops/sec ±0.36% (89 runs sampled)
550
- -resolver.reverse without caching x 1.90 ops/sec ±210.52% (16 runs sampled)
551
- dns.promises.reverse with caching x 5,123,900 ops/sec ±0.96% (85 runs sampled)
552
- -dns.promises.reverse without caching x 0.29 ops/sec ±171.85% (18 runs sampled)
553
- +Fastest without caching is: tangerine.reverse GET without caching
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
554
592
  ```
555
593
 
556
- > [Node 18 on ubuntu latest](https://github.com/forwardemail/nodejs-dns-over-https-tangerine/actions/runs/4297805550/jobs/7491228742#step:6:1)
594
+ ##### Node.js v20.19.6
557
595
 
558
- ```diff
559
- node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse && node benchmarks/http
596
+ **lookup:**
560
597
 
598
+ ```
561
599
  Started: lookup
562
- tangerine.lookup POST with caching using Cloudflare x 666 ops/sec ±195.48% (87 runs sampled)
563
- tangerine.lookup POST without caching using Cloudflare x 90.81 ops/sec ±8.06% (89 runs sampled)
564
- tangerine.lookup GET with caching using Cloudflare x 256,141 ops/sec ±1.72% (87 runs sampled)
565
- +tangerine.lookup GET without caching using Cloudflare x 96.39 ops/sec ±0.31% (89 runs sampled)
566
- dns.promises.lookup with caching using Cloudflare x 1,473 ops/sec ±195.95% (87 runs sampled)
567
- -dns.promises.lookup without caching using Cloudflare x 4,191 ops/sec ±0.54% (85 runs sampled)
600
+ tangerine.lookup POST with caching using Cloudflare x 795 ops/sec ±195.51% (87 runs sampled)
601
+ tangerine.lookup POST without caching using Cloudflare x 306 ops/sec ±1.81% (83 runs sampled)
602
+ tangerine.lookup GET with caching using Cloudflare x 298,127 ops/sec ±0.28% (90 runs sampled)
603
+ tangerine.lookup GET without caching using Cloudflare x 307 ops/sec ±1.94% (83 runs sampled)
604
+ dns.promises.lookup with caching using Cloudflare x 9,509,305 ops/sec ±0.47% (88 runs sampled)
605
+ dns.promises.lookup without caching using Cloudflare x 3,250 ops/sec ±0.73% (85 runs sampled)
568
606
  Fastest without caching is: dns.promises.lookup without caching using Cloudflare
607
+ ```
608
+
609
+ **resolve:**
569
610
 
611
+ ```
570
612
  Started: resolve
571
- tangerine.resolve POST with caching using Cloudflare x 683 ops/sec ±195.88% (87 runs sampled)
572
- tangerine.resolve POST without caching using Cloudflare x 93.37 ops/sec ±0.48% (87 runs sampled)
573
- tangerine.resolve GET with caching using Cloudflare x 1,146,727 ops/sec ±0.58% (88 runs sampled)
574
- +tangerine.resolve GET without caching using Cloudflare x 93.33 ops/sec ±0.51% (87 runs sampled)
575
- tangerine.resolve POST with caching using Google x 1,133,683 ops/sec ±2.74% (89 runs sampled)
576
- tangerine.resolve POST without caching using Google x 83.91 ops/sec ±6.32% (76 runs sampled)
577
- tangerine.resolve GET with caching using Google x 1,147,212 ops/sec ±0.32% (90 runs sampled)
578
- tangerine.resolve GET without caching using Google x 79.73 ops/sec ±4.02% (77 runs sampled)
579
- resolver.resolve with caching using Cloudflare x 5,318,406 ops/sec ±0.67% (86 runs sampled)
580
- -resolver.resolve without caching using Cloudflare x 100 ops/sec ±1.55% (79 runs sampled)
581
- Fastest without caching is: resolver.resolve without caching using Cloudflare
613
+ tangerine.resolve POST with caching using Cloudflare x 1,016 ops/sec ±195.82% (89 runs sampled)
614
+ tangerine.resolve POST without caching using Cloudflare x 303 ops/sec ±3.25% (79 runs sampled)
615
+ tangerine.resolve GET with caching using Cloudflare x 1,071,513 ops/sec ±0.32% (89 runs sampled)
616
+ tangerine.resolve GET without caching using Cloudflare x 339 ops/sec ±1.13% (84 runs sampled)
617
+ tangerine.resolve POST with caching using Google x 1,031,970 ops/sec ±0.28% (90 runs sampled)
618
+ tangerine.resolve POST without caching using Google x 356 ops/sec ±13.06% (79 runs sampled)
619
+ tangerine.resolve GET with caching using Google x 1,032,838 ops/sec ±0.28% (90 runs sampled)
620
+ tangerine.resolve GET without caching using Google x 364 ops/sec ±5.78% (77 runs sampled)
621
+ resolver.resolve with caching using Cloudflare x 7,820,907 ops/sec ±0.65% (86 runs sampled)
622
+ resolver.resolve without caching using Cloudflare x 16.20 ops/sec ±189.31% (79 runs sampled)
623
+ Fastest without caching is: tangerine.resolve GET without caching using Google
624
+ ```
582
625
 
626
+ **reverse:**
627
+
628
+ ```
583
629
  Started: reverse
584
- tangerine.reverse GET with caching x 722 ops/sec ±195.42% (88 runs sampled)
585
- +tangerine.reverse GET without caching x 93.19 ops/sec ±0.74% (87 runs sampled)
586
- resolver.reverse with caching x 5,520,569 ops/sec ±0.59% (85 runs sampled)
587
- -resolver.reverse without caching x 17.42 ops/sec ±162.63% (70 runs sampled)
588
- dns.promises.reverse with caching x 5,164,258 ops/sec ±0.96% (86 runs sampled)
589
- -dns.promises.reverse without caching x 0.20 ops/sec ±184.87% (25 runs sampled)
590
- +Fastest without caching is: tangerine.reverse GET without caching
630
+ tangerine.reverse GET with caching x 1,002 ops/sec ±195.36% (87 runs sampled)
631
+ tangerine.reverse GET without caching x 323 ops/sec ±1.36% (85 runs sampled)
632
+ resolver.reverse with caching x 78.33 ops/sec ±196.00% (88 runs sampled)
633
+ resolver.reverse without caching x 0.10 ops/sec ±23.12% (5 runs sampled)
634
+ dns.promises.reverse with caching x 7,759,048 ops/sec ±1.08% (79 runs sampled)
635
+ dns.promises.reverse without caching x 1.25 ops/sec ±163.63% (80 runs sampled)
636
+ Fastest without caching is: tangerine.reverse GET without caching
591
637
  ```
592
638
 
593
- ---
639
+ ##### Node.js v22.21.1
594
640
 
595
- You can also [run the benchmarks yourself](#benchmarks).
641
+ **lookup:**
596
642
 
597
- ---
643
+ ```
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
+ ```
598
653
 
599
- Provided below are additional benchmark tests we have run:
654
+ **resolve:**
600
655
 
601
- > Node v18.14.2 on MacBook Air M1 16GB (without VPN):
656
+ ```
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
+ ```
602
670
 
603
- ```diff
604
- node --version
605
- v18.14.2
671
+ **reverse:**
672
+
673
+ ```
674
+ spawnSync /bin/sh ETIMEDOUT
675
+ ```
606
676
 
607
- node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse
677
+ ##### Node.js v24.12.0
608
678
 
679
+ **lookup:**
680
+
681
+ ```
609
682
  Started: lookup
610
- tangerine.lookup POST with caching using Cloudflare x 1,035 ops/sec ±195.73% (91 runs sampled)
611
- tangerine.lookup POST without caching using Cloudflare x 52.76 ops/sec ±51.29% (53 runs sampled)
612
- tangerine.lookup GET with caching using Cloudflare x 694,910 ops/sec ±1.54% (87 runs sampled)
613
- +tangerine.lookup GET without caching using Cloudflare x 40.18 ops/sec ±60.19% (49 runs sampled)
614
- dns.promises.lookup with caching using Cloudflare x 12,645,103 ops/sec ±0.26% (90 runs sampled)
615
- -dns.promises.lookup without caching using Cloudflare x 2,664 ops/sec ±0.54% (88 runs sampled)
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)
616
689
  Fastest without caching is: dns.promises.lookup without caching using Cloudflare
690
+ ```
617
691
 
618
- Started: resolve
619
- tangerine.resolve POST with caching using Cloudflare x 1,005 ops/sec ±195.93% (91 runs sampled)
620
- tangerine.resolve POST without caching using Cloudflare x 55.52 ops/sec ±46.26% (57 runs sampled)
621
- tangerine.resolve GET with caching using Cloudflare x 2,879,865 ops/sec ±0.35% (86 runs sampled)
622
- +tangerine.resolve GET without caching using Cloudflare x 71.11 ops/sec ±2.94% (74 runs sampled)
623
- tangerine.resolve POST with caching using Google x 1,292 ops/sec ±195.91% (88 runs sampled)
624
- tangerine.resolve POST without caching using Google x 36.88 ops/sec ±41.76% (53 runs sampled)
625
- tangerine.resolve GET with caching using Google x 2,885,428 ops/sec ±0.22% (88 runs sampled)
626
- tangerine.resolve GET without caching using Google x 70.38 ops/sec ±3.72% (68 runs sampled)
627
- resolver.resolve with caching using Cloudflare x 10,645,813 ops/sec ±0.23% (91 runs sampled)
628
- -resolver.resolve without caching using Cloudflare x 71.80 ops/sec ±2.84% (67 runs sampled)
629
- +Fastest without caching is: resolver.resolve without caching using Cloudflare, tangerine.resolve GET without caching using Cloudflare, tangerine.resolve GET without caching using Google, tangerine.resolve POST without caching using Cloudflare
692
+ **resolve:**
630
693
 
631
- Started: reverse
632
- tangerine.reverse GET with caching x 917 ops/sec ±195.78% (88 runs sampled)
633
- +tangerine.reverse GET without caching x 51.15 ops/sec ±51.92% (61 runs sampled)
634
- resolver.reverse with caching x 11,058,579 ops/sec ±0.37% (88 runs sampled)
635
- -resolver.reverse without caching x 62.30 ops/sec ±24.83% (64 runs sampled)
636
- dns.promises.reverse with caching x 11,276,123 ops/sec ±0.17% (90 runs sampled)
637
- -dns.promises.reverse without caching x 73.46 ops/sec ±1.99% (69 runs sampled)
638
- Fastest without caching is: dns.promises.reverse without caching, resolver.reverse without caching
694
+ ```
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
639
707
  ```
640
708
 
641
- > Node v18.14.2 on MacBook Air M1 16GB (with DNS blackholed VPN) – **this highlights the DNS blackhole problem**:
709
+ **reverse:**
642
710
 
643
- ```diff
644
- node --version
645
- v18.14.2
711
+ ```
712
+ spawnSync /bin/sh ETIMEDOUT
713
+ ```
714
+
715
+ ##### Node.js v25.2.1
646
716
 
647
- node benchmarks/lookup && node benchmarks/resolve && node benchmarks/reverse
717
+ **lookup:**
648
718
 
719
+ ```
649
720
  Started: lookup
650
- tangerine.lookup POST with caching using Cloudflare x 1,327 ops/sec ±195.65% (89 runs sampled)
651
- tangerine.lookup POST without caching using Cloudflare x 71.11 ops/sec ±8.24% (71 runs sampled)
652
- tangerine.lookup GET with caching using Cloudflare x 759,816 ops/sec ±0.46% (90 runs sampled)
653
- +tangerine.lookup GET without caching using Cloudflare x 73.98 ops/sec ±1.78% (69 runs sampled)
654
- dns.promises.lookup with caching using Cloudflare x 1,744 ops/sec ±195.97% (88 runs sampled)
655
- -dns.promises.lookup without caching using Cloudflare x 2,717 ops/sec ±0.82% (87 runs sampled)
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)
656
727
  Fastest without caching is: dns.promises.lookup without caching using Cloudflare
728
+ ```
729
+
730
+ **resolve:**
657
731
 
732
+ ```
658
733
  Started: resolve
659
- tangerine.resolve POST with caching using Cloudflare x 947 ops/sec ±195.93% (91 runs sampled)
660
- tangerine.resolve POST without caching using Cloudflare x 44.33 ops/sec ±73.30% (75 runs sampled)
661
- tangerine.resolve GET with caching using Cloudflare x 2,814,737 ops/sec ±0.17% (91 runs sampled)
662
- +tangerine.resolve GET without caching using Cloudflare x 57.25 ops/sec ±51.61% (73 runs sampled)
663
- tangerine.resolve POST with caching using Google x 1,087 ops/sec ±195.92% (91 runs sampled)
664
- tangerine.resolve POST without caching using Google x 36.84 ops/sec ±7.04% (62 runs sampled)
665
- tangerine.resolve GET with caching using Google x 2,784,199 ops/sec ±0.15% (92 runs sampled)
666
- tangerine.resolve GET without caching using Google x 47.55 ops/sec ±5.66% (76 runs sampled)
667
- resolver.resolve with caching using Cloudflare x 0.09 ops/sec ±6.41% (5 runs sampled)
668
- -resolver.resolve without caching using Cloudflare x 0.10 ops/sec ±6.52% (5 runs sampled)
669
- +Fastest without caching is: tangerine.resolve GET without caching using Google
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:**
670
748
 
749
+ ```
671
750
  Started: reverse
672
- tangerine.reverse GET with caching x 1,345 ops/sec ±195.66% (92 runs sampled)
673
- +tangerine.reverse GET without caching x 71.73 ops/sec ±3.03% (73 runs sampled)
674
- resolver.reverse with caching x 0.10 ops/sec ±6.54% (5 runs sampled)
675
- -resolver.reverse without caching x 0.10 ops/sec ±0.01% (5 runs sampled)
676
- dns.promises.reverse with caching x 0.10 ops/sec ±6.54% (5 runs sampled)
677
- -dns.promises.reverse without caching x 0.10 ops/sec ±0.01% (5 runs sampled)
678
- +Fastest without caching is: tangerine.reverse GET without caching
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
679
758
  ```
680
759
 
760
+ </details>
761
+
762
+ <!-- BENCHMARK_RESULTS_END -->
763
+
764
+ ---
765
+
766
+ The benchmarks above are automatically updated daily via `.github/workflows/daily-benchmarks.yml`.
767
+
768
+ You can also [run the benchmarks yourself](#benchmarks).
769
+
770
+ ---
771
+
681
772
  Also see this [write-up](https://samknows.com/blog/dns-over-https-performance) on UDP-based DNS versus DNS over HTTPS ("DoH") benchmarks.
682
773
 
683
774
  **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).
package/index.js CHANGED
@@ -17,7 +17,6 @@ const pMap = require('p-map');
17
17
  const pWaitFor = require('p-wait-for');
18
18
  const packet = require('dns-packet');
19
19
  const semver = require('semver');
20
- const structuredClone = require('@ungap/structured-clone').default;
21
20
  const { getService } = require('port-numbers');
22
21
  const pkg = require('./package.json');
23
22
 
@@ -50,6 +49,14 @@ for (const line of hosts) {
50
49
  HOSTS.push({ ip, hosts });
51
50
  }
52
51
 
52
+ // Node.js v24+ adds a 'type' property to certain DNS record objects (MX, CAA, SRV, NAPTR)
53
+ // We need to match this behavior for compatibility
54
+ // See: https://github.com/nodejs/node/blob/main/doc/changelogs/CHANGELOG_V24.md
55
+ const NODE_MAJOR_VERSION = Number.parseInt(
56
+ process.versions.node.split('.')[0],
57
+ 10
58
+ );
59
+
53
60
  // <https://github.com/szmarczak/cacheable-lookup/pull/76>
54
61
  class Tangerine extends dns.promises.Resolver {
55
62
  static HOSTFILE = HOSTFILE;
@@ -633,8 +640,8 @@ class Tangerine extends dns.promises.Resolver {
633
640
  // remap and perform syscall
634
641
  err.syscall = 'getaddrinfo';
635
642
  err.message = err.message.replace('query', 'getaddrinfo');
636
- err.errno = -3008; // <-- ?
637
- // err.errno = -3007;
643
+ // errno -3007 is for invalid hostnames (like ".")
644
+ err.errno = -3007;
638
645
  throw err;
639
646
  }
640
647
 
@@ -735,19 +742,27 @@ class Tangerine extends dns.promises.Resolver {
735
742
  }
736
743
  }
737
744
 
738
- if (
739
- answers.length === 0 &&
740
- errors.length > 0 &&
741
- errors.every((e) => e.code === errors[0].code)
742
- ) {
743
- const err = this.constructor.createError(
744
- name,
745
- '',
746
- errors[0].code === dns.BADNAME ? dns.NOTFOUND : errors[0].code
747
- );
745
+ if (answers.length === 0 && errors.length > 0) {
746
+ // For lookup, if any error is ENOTFOUND, return ENOTFOUND
747
+ // For .localhost subdomains, BADNAME, and ENODATA errors, return ENOTFOUND
748
+ // This matches c-ares behavior for lookup
749
+ let errorCode = errors[0].code;
750
+ const hasNotFound = errors.some((e) => e.code === dns.NOTFOUND);
751
+ if (
752
+ hasNotFound ||
753
+ errorCode === dns.BADNAME ||
754
+ errorCode === dns.NODATA ||
755
+ lower.endsWith('.localhost') ||
756
+ lower.endsWith('.localhost.')
757
+ ) {
758
+ errorCode = dns.NOTFOUND;
759
+ }
760
+
761
+ const err = this.constructor.createError(name, '', errorCode);
748
762
  // remap and perform syscall
749
763
  err.syscall = 'getaddrinfo';
750
764
  err.message = err.message.replace('query', 'getaddrinfo');
765
+ // errno -3008 is the standard ENOTFOUND errno
751
766
  err.errno = -3008;
752
767
  throw err;
753
768
  }
@@ -936,7 +951,8 @@ class Tangerine extends dns.promises.Resolver {
936
951
  for (const rule of this.constructor.HOSTS) {
937
952
  if (rule.ip === ip) {
938
953
  match = true;
939
- for (const host of rule.hosts.slice(1)) {
954
+ // Include all hosts (c-ares includes all hosts from /etc/hosts)
955
+ for (const host of rule.hosts) {
940
956
  answers.add(host);
941
957
  }
942
958
  }
@@ -1030,7 +1046,32 @@ class Tangerine extends dns.promises.Resolver {
1030
1046
  // (this means it's a drop-in replacement for `dns`)
1031
1047
  // <https://github.com/nodejs/node/blob/9bbde3d7baef584f14569ef79f116e9d288c7aaa/lib/internal/dns/utils.js#L87-L95>
1032
1048
  getServers() {
1033
- return [...this.options.servers];
1049
+ // Normalize IPv6 addresses to match native dns.Resolver behavior
1050
+ // e.g., '[::0]' -> '::' but '[2001:db8::1]:8080' stays as-is
1051
+ return [...this.options.servers].map((server) => {
1052
+ // Check if it's a bracketed IPv6 address
1053
+ if (server.startsWith('[')) {
1054
+ // Extract the IPv6 address and optional port
1055
+ const match = server.match(/^\[([^\]]+)](?::(\d+))?$/);
1056
+ if (match) {
1057
+ const ipv6 = match[1];
1058
+ const port = match[2];
1059
+ // Normalize the IPv6 address using ipaddr.js
1060
+ try {
1061
+ const parsed = ipaddr.parse(ipv6);
1062
+ const normalized = parsed.toString();
1063
+ // If there's a port, keep the bracket format like native DNS does
1064
+ // Otherwise just return the normalized address
1065
+ return port ? `[${normalized}]:${port}` : normalized;
1066
+ } catch {
1067
+ // If parsing fails, return as-is
1068
+ return server;
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ return server;
1074
+ });
1034
1075
  }
1035
1076
 
1036
1077
  //
@@ -1072,7 +1113,9 @@ class Tangerine extends dns.promises.Resolver {
1072
1113
  }
1073
1114
 
1074
1115
  debug('request', { url, options });
1075
- const t = setTimeout(() => abortController.abort(), timeout);
1116
+ const t = setTimeout(() => {
1117
+ if (!abortController?.signal?.aborted) abortController.abort('ETIMEOUT');
1118
+ }, timeout);
1076
1119
  const response = await this.request(url, options);
1077
1120
  clearTimeout(t);
1078
1121
  return response;
@@ -1216,7 +1259,12 @@ class Tangerine extends dns.promises.Resolver {
1216
1259
  if (errors.length > 0) throw this.constructor.combineErrors(errors);
1217
1260
  // if no errors and no response
1218
1261
  // that must indicate that it was aborted
1219
- throw this.constructor.createError(name, rrtype, dns.CANCELLED);
1262
+ // Check if this was a timeout abort (reason will be 'ETIMEOUT')
1263
+ const abortCode =
1264
+ abortController?.signal?.reason === 'ETIMEOUT'
1265
+ ? dns.TIMEOUT
1266
+ : dns.CANCELLED;
1267
+ throw this.constructor.createError(name, rrtype, abortCode);
1220
1268
  }
1221
1269
 
1222
1270
  // without logging an error here, one might not know
@@ -1233,10 +1281,20 @@ class Tangerine extends dns.promises.Resolver {
1233
1281
  } catch (_err) {
1234
1282
  debug(_err, { name, rrtype, ecsSubnet });
1235
1283
  if (this.options.returnHTTPErrors) throw _err;
1284
+ // Check if this was a timeout abort (reason will be 'ETIMEOUT')
1285
+ // or if the error name is AbortError with a numeric code (undici behavior)
1286
+ let errorCode = _err.code;
1287
+ if (
1288
+ (_err.name === 'AbortError' || typeof _err.code === 'number') &&
1289
+ abortController?.signal?.reason === 'ETIMEOUT'
1290
+ ) {
1291
+ errorCode = 'ETIMEOUT';
1292
+ }
1293
+
1236
1294
  const err = this.constructor.createError(
1237
1295
  name,
1238
1296
  rrtype,
1239
- _err.code,
1297
+ errorCode,
1240
1298
  _err.errno
1241
1299
  );
1242
1300
  // then map it to dns.CONNREFUSED
@@ -1247,6 +1305,23 @@ class Tangerine extends dns.promises.Resolver {
1247
1305
  }
1248
1306
  }
1249
1307
 
1308
+ // #createAbortController and #releaseAbortController manage all AbortController instances created by this resolver
1309
+ // - to support cancel() and
1310
+ // - to avoid keeping references in this.abortControllers after a query is finished (which would create a memory leak)
1311
+ #createAbortController() {
1312
+ const abortController = new AbortController();
1313
+ this.abortControllers.add(abortController);
1314
+ return abortController;
1315
+ }
1316
+
1317
+ #releaseAbortController(abortController) {
1318
+ try {
1319
+ this.abortControllers.delete(abortController);
1320
+ } catch (err) {
1321
+ this.options.logger.debug(err);
1322
+ }
1323
+ }
1324
+
1250
1325
  // Cancel all outstanding DNS queries made by this resolver
1251
1326
  // NOTE: callbacks not currently called with ECANCELLED (prob need to alter got options)
1252
1327
  // (instead they are called with "ABORT_ERR"; see ABORT_ERROR_CODES)
@@ -1264,120 +1339,124 @@ class Tangerine extends dns.promises.Resolver {
1264
1339
 
1265
1340
  #resolveByType(name, options = {}, parentAbortController) {
1266
1341
  return async (type) => {
1267
- const abortController = new AbortController();
1268
- this.abortControllers.add(abortController);
1269
- abortController.signal.addEventListener(
1270
- 'abort',
1271
- () => {
1272
- this.abortControllers.delete(abortController);
1273
- },
1274
- { once: true }
1275
- );
1276
- parentAbortController.signal.addEventListener(
1277
- 'abort',
1278
- () => {
1279
- try {
1280
- abortController.abort('Parent abort controller aborted');
1281
- } catch (err) {
1282
- this.options.logger.debug(err);
1283
- }
1284
- },
1285
- { once: true }
1286
- );
1287
- // wrap with try/catch because ENODATA shouldn't cause errors
1342
+ const abortController = this.#createAbortController();
1288
1343
  try {
1289
- switch (type) {
1290
- case 'A': {
1291
- const result = await this.resolve4(
1292
- name,
1293
- { ...options, ttl: true },
1294
- abortController
1295
- );
1296
- return result.map((r) => ({ type, ...r }));
1297
- }
1344
+ parentAbortController.signal.addEventListener(
1345
+ 'abort',
1346
+ () => {
1347
+ try {
1348
+ abortController.abort('Parent abort controller aborted');
1349
+ } catch (err) {
1350
+ this.options.logger.debug(err);
1351
+ }
1352
+ },
1353
+ { once: true }
1354
+ );
1355
+ // wrap with try/catch because ENODATA shouldn't cause errors
1356
+ try {
1357
+ switch (type) {
1358
+ case 'A': {
1359
+ const result = await this.resolve4(
1360
+ name,
1361
+ { ...options, ttl: true },
1362
+ abortController
1363
+ );
1364
+ return result.map((r) => ({ type, ...r }));
1365
+ }
1298
1366
 
1299
- case 'AAAA': {
1300
- const result = await this.resolve6(
1301
- name,
1302
- { ...options, ttl: true },
1303
- abortController
1304
- );
1305
- return result.map((r) => ({ type, ...r }));
1306
- }
1367
+ case 'AAAA': {
1368
+ const result = await this.resolve6(
1369
+ name,
1370
+ { ...options, ttl: true },
1371
+ abortController
1372
+ );
1373
+ return result.map((r) => ({ type, ...r }));
1374
+ }
1307
1375
 
1308
- case 'CNAME': {
1309
- const result = await this.resolveCname(
1310
- name,
1311
- options,
1312
- abortController
1313
- );
1314
- return result.map((value) => ({ type, value }));
1315
- }
1376
+ case 'CNAME': {
1377
+ const result = await this.resolveCname(
1378
+ name,
1379
+ options,
1380
+ abortController
1381
+ );
1382
+ return result.map((value) => ({ type, value }));
1383
+ }
1316
1384
 
1317
- case 'MX': {
1318
- const result = await this.resolveMx(name, options, abortController);
1319
- return result.map((r) => ({ type, ...r }));
1320
- }
1385
+ case 'MX': {
1386
+ const result = await this.resolveMx(
1387
+ name,
1388
+ options,
1389
+ abortController
1390
+ );
1391
+ return result.map((r) => ({ type, ...r }));
1392
+ }
1321
1393
 
1322
- case 'NAPTR': {
1323
- const result = await this.resolveNaptr(
1324
- name,
1325
- options,
1326
- abortController
1327
- );
1328
- return result.map((value) => ({ type, value }));
1329
- }
1394
+ case 'NAPTR': {
1395
+ const result = await this.resolveNaptr(
1396
+ name,
1397
+ options,
1398
+ abortController
1399
+ );
1400
+ return result.map((value) => ({ type, value }));
1401
+ }
1330
1402
 
1331
- case 'NS': {
1332
- const result = await this.resolveNs(name, options, abortController);
1333
- return result.map((value) => ({ type, value }));
1334
- }
1403
+ case 'NS': {
1404
+ const result = await this.resolveNs(
1405
+ name,
1406
+ options,
1407
+ abortController
1408
+ );
1409
+ return result.map((value) => ({ type, value }));
1410
+ }
1335
1411
 
1336
- case 'PTR': {
1337
- const result = await this.resolvePtr(
1338
- name,
1339
- options,
1340
- abortController
1341
- );
1342
- return result.map((value) => ({ type, value }));
1343
- }
1412
+ case 'PTR': {
1413
+ const result = await this.resolvePtr(
1414
+ name,
1415
+ options,
1416
+ abortController
1417
+ );
1418
+ return result.map((value) => ({ type, value }));
1419
+ }
1344
1420
 
1345
- case 'SOA': {
1346
- const result = await this.resolveSoa(
1347
- name,
1348
- options,
1349
- abortController
1350
- );
1351
- return { type, ...result };
1352
- }
1421
+ case 'SOA': {
1422
+ const result = await this.resolveSoa(
1423
+ name,
1424
+ options,
1425
+ abortController
1426
+ );
1427
+ return { type, ...result };
1428
+ }
1353
1429
 
1354
- case 'SRV': {
1355
- const result = await this.resolveSrv(
1356
- name,
1357
- options,
1358
- abortController
1359
- );
1360
- return result.map((value) => ({ type, value }));
1361
- }
1430
+ case 'SRV': {
1431
+ const result = await this.resolveSrv(
1432
+ name,
1433
+ options,
1434
+ abortController
1435
+ );
1436
+ return result.map((value) => ({ type, value }));
1437
+ }
1362
1438
 
1363
- case 'TXT': {
1364
- const result = await this.resolveTxt(
1365
- name,
1366
- options,
1367
- abortController
1368
- );
1369
- return result.map((entries) => ({ type, entries }));
1370
- }
1439
+ case 'TXT': {
1440
+ const result = await this.resolveTxt(
1441
+ name,
1442
+ options,
1443
+ abortController
1444
+ );
1445
+ return result.map((entries) => ({ type, entries }));
1446
+ }
1371
1447
 
1372
- default: {
1373
- break;
1448
+ default: {
1449
+ break;
1450
+ }
1374
1451
  }
1375
- }
1376
- } catch (err) {
1377
- debug(err);
1452
+ } catch (err) {
1453
+ debug(err);
1378
1454
 
1379
- if (err.code === dns.NODATA) return;
1380
- throw err;
1455
+ if (err.code === dns.NODATA) return;
1456
+ throw err;
1457
+ }
1458
+ } finally {
1459
+ this.#releaseAbortController(abortController);
1381
1460
  }
1382
1461
  };
1383
1462
  }
@@ -1393,23 +1472,22 @@ class Tangerine extends dns.promises.Resolver {
1393
1472
  // <https://gist.github.com/andrewcourtice/ef1b8f14935b409cfe94901558ba5594#file-task-ts-L37>
1394
1473
  // <https://github.com/nodejs/undici/blob/0badd390ad5aa531a66aacee54da664468aa1577/lib/api/api-fetch/request.js#L280-L295>
1395
1474
  // <https://github.com/nodejs/node/issues/40849>
1475
+ let mustReleaseAbortController = false;
1396
1476
  if (!abortController) {
1397
- abortController = new AbortController();
1398
- this.abortControllers.add(abortController);
1399
- abortController.signal.addEventListener(
1400
- 'abort',
1401
- () => {
1402
- this.abortControllers.delete(abortController);
1403
- },
1404
- { once: true }
1405
- );
1477
+ abortController = this.#createAbortController();
1478
+ mustReleaseAbortController = true;
1406
1479
 
1407
- // <https://github.com/nodejs/undici/pull/1910/commits/7615308a92d3c8c90081fb99c55ab8bd59212396>
1408
- setMaxListeners(
1409
- getEventListeners(abortController.signal, 'abort').length +
1410
- this.constructor.ANY_TYPES.length,
1411
- abortController.signal
1412
- );
1480
+ try {
1481
+ // <https://github.com/nodejs/undici/pull/1910/commits/7615308a92d3c8c90081fb99c55ab8bd59212396>
1482
+ setMaxListeners(
1483
+ getEventListeners(abortController.signal, 'abort').length +
1484
+ this.constructor.ANY_TYPES.length,
1485
+ abortController.signal
1486
+ );
1487
+ } catch (err) {
1488
+ this.#releaseAbortController(abortController);
1489
+ throw err;
1490
+ }
1413
1491
  }
1414
1492
 
1415
1493
  try {
@@ -1427,6 +1505,10 @@ class Tangerine extends dns.promises.Resolver {
1427
1505
  err.syscall = 'queryAny';
1428
1506
  err.message = `queryAny ${err.code} ${name}`;
1429
1507
  throw err;
1508
+ } finally {
1509
+ if (mustReleaseAbortController) {
1510
+ this.#releaseAbortController(abortController);
1511
+ }
1430
1512
  }
1431
1513
  }
1432
1514
 
@@ -1553,11 +1635,80 @@ class Tangerine extends dns.promises.Resolver {
1553
1635
  throw err;
1554
1636
  }
1555
1637
 
1556
- // edge case where c-ares detects "." as start of string
1638
+ // edge case where c-ares detects "." as start of string or malformed hostnames
1557
1639
  // <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares_getaddrinfo.c#L829>
1558
- if (name !== '.' && (name.startsWith('.') || name.includes('..')))
1640
+ if (
1641
+ (name !== '.' && (name.startsWith('.') || name.includes('..'))) ||
1642
+ name.includes(',')
1643
+ )
1559
1644
  throw this.constructor.createError(name, rrtype, dns.BADNAME);
1560
1645
 
1646
+ // IP addresses passed to resolve should return appropriate errors (matches c-ares behavior)
1647
+ // IPv6 addresses return EBADNAME for all record types
1648
+ // IPv4 addresses return ENOTFOUND for A records, ENODATA for AAAA records
1649
+ // <https://github.com/c-ares/c-ares/blob/main/src/lib/ares_getaddrinfo.c>
1650
+ if (isIPv6(name))
1651
+ throw this.constructor.createError(name, rrtype, dns.BADNAME);
1652
+ if (isIPv4(name)) {
1653
+ // For AAAA queries on IPv4 addresses, return ENODATA (no IPv6 data for IPv4 address)
1654
+ if (rrtype === 'AAAA')
1655
+ throw this.constructor.createError(name, rrtype, dns.NODATA);
1656
+ throw this.constructor.createError(name, rrtype, dns.NOTFOUND);
1657
+ }
1658
+
1659
+ // Handle localhost and .localhost domains for A and AAAA records
1660
+ // This mirrors c-ares behavior which returns results from /etc/hosts
1661
+ // <https://www.rfc-editor.org/rfc/rfc6761.html#section-6.3>
1662
+ const lower = name.toLowerCase();
1663
+ if (
1664
+ (rrtype === 'A' || rrtype === 'AAAA') &&
1665
+ (lower === 'localhost' ||
1666
+ lower === 'localhost.' ||
1667
+ lower.endsWith('.localhost') ||
1668
+ lower.endsWith('.localhost.'))
1669
+ ) {
1670
+ // Check /etc/hosts first
1671
+ let resolve4;
1672
+ let resolve6;
1673
+ for (const rule of this.constructor.HOSTS) {
1674
+ if (rule.hosts.every((h) => h.toLowerCase() !== lower)) continue;
1675
+ const type = isIP(rule.ip);
1676
+ if (!resolve4 && type === 4) {
1677
+ resolve4 = [rule.ip];
1678
+ } else if (!resolve6 && type === 6) {
1679
+ resolve6 = [rule.ip];
1680
+ }
1681
+ }
1682
+
1683
+ // Default fallback for localhost
1684
+ if (lower === 'localhost' || lower === 'localhost.') {
1685
+ resolve4 ||= ['127.0.0.1'];
1686
+ resolve6 ||= ['::1'];
1687
+ }
1688
+
1689
+ if (rrtype === 'A' && resolve4) {
1690
+ if (options?.ttl)
1691
+ return resolve4.map((address) => ({ ttl: 0, address }));
1692
+ return resolve4;
1693
+ }
1694
+
1695
+ if (rrtype === 'AAAA' && resolve6) {
1696
+ if (options?.ttl)
1697
+ return resolve6.map((address) => ({ ttl: 0, address }));
1698
+ return resolve6;
1699
+ }
1700
+
1701
+ // If no matching records found, throw appropriate error
1702
+ // For A records on subdomains of .localhost, throw ENOTFOUND
1703
+ // For AAAA records on subdomains of .localhost, throw ENODATA
1704
+ // This matches c-ares behavior
1705
+ if (rrtype === 'A') {
1706
+ throw this.constructor.createError(name, rrtype, dns.NOTFOUND);
1707
+ }
1708
+
1709
+ throw this.constructor.createError(name, rrtype, dns.NODATA);
1710
+ }
1711
+
1561
1712
  // purge cache support
1562
1713
  let purgeCache;
1563
1714
  if (options?.purgeCache) {
@@ -1619,7 +1770,6 @@ class Tangerine extends dns.promises.Resolver {
1619
1770
  const diff = data.ttl - ttl;
1620
1771
 
1621
1772
  for (let i = 0; i < data.answers.length; i++) {
1622
- // eslint-disable-next-line max-depth
1623
1773
  if (typeof data.answers[i].ttl === 'number') {
1624
1774
  // subtract ttl from answer
1625
1775
  data.answers[i].ttl = Math.round(data.answers[i].ttl - diff);
@@ -1673,20 +1823,20 @@ class Tangerine extends dns.promises.Resolver {
1673
1823
  if (this.options.cache && result) {
1674
1824
  debug(`cached result found for "${key}"`);
1675
1825
  } else {
1826
+ let mustReleaseAbortController = false;
1676
1827
  if (!abortController) {
1677
- abortController = new AbortController();
1678
- this.abortControllers.add(abortController);
1679
- abortController.signal.addEventListener(
1680
- 'abort',
1681
- () => {
1682
- this.abortControllers.delete(abortController);
1683
- },
1684
- { once: true }
1685
- );
1828
+ abortController = this.#createAbortController();
1829
+ mustReleaseAbortController = true;
1686
1830
  }
1687
1831
 
1688
- // setImmediate(() => this.cancel());
1689
- result = await this.#query(name, rrtype, ecsSubnet, abortController);
1832
+ try {
1833
+ // setImmediate(() => this.cancel());
1834
+ result = await this.#query(name, rrtype, ecsSubnet, abortController);
1835
+ } finally {
1836
+ if (mustReleaseAbortController) {
1837
+ this.#releaseAbortController(abortController);
1838
+ }
1839
+ }
1690
1840
  }
1691
1841
 
1692
1842
  // <https://github.com/m13253/dns-over-https/blob/2e36b4ebcdb8a1a102ea86370d7f8b1f1e72380a/json-dns/response.go#L50-L74>
@@ -1810,10 +1960,24 @@ class Tangerine extends dns.promises.Resolver {
1810
1960
  case 'CAA': {
1811
1961
  // CA authorization records `dnsPromises.resolveCaa()`
1812
1962
  // <https://www.rfc-editor.org/rfc/rfc6844#section-3>
1813
- return result.answers.map((a) => ({
1814
- critical: a.data.flags,
1815
- [a.data.tag]: a.data.value
1816
- }));
1963
+ // Node.js v24+ adds 'type' property to CAA records
1964
+ return result.answers.map((a) => {
1965
+ const record = {
1966
+ critical: a.data.flags,
1967
+ [a.data.tag]: a.data.value
1968
+ };
1969
+ // Add type property for Node.js v24+ compatibility
1970
+ if (NODE_MAJOR_VERSION >= 24) {
1971
+ // Insert type after critical to match native DNS order
1972
+ return {
1973
+ critical: record.critical,
1974
+ type: 'CAA',
1975
+ [a.data.tag]: a.data.value
1976
+ };
1977
+ }
1978
+
1979
+ return record;
1980
+ });
1817
1981
  }
1818
1982
 
1819
1983
  case 'CNAME': {
@@ -1823,15 +1987,34 @@ class Tangerine extends dns.promises.Resolver {
1823
1987
 
1824
1988
  case 'MX': {
1825
1989
  // mail exchange records `dnsPromises.resolveMx()`
1826
- return result.answers.map((a) => ({
1827
- exchange: a.data.exchange,
1828
- priority: a.data.preference
1829
- }));
1990
+ // Node.js v24+ adds 'type' property to MX records
1991
+ return result.answers.map((a) => {
1992
+ const record = {
1993
+ exchange: a.data.exchange,
1994
+ priority: a.data.preference
1995
+ };
1996
+ // Add type property for Node.js v24+ compatibility
1997
+ if (NODE_MAJOR_VERSION >= 24) {
1998
+ record.type = 'MX';
1999
+ }
2000
+
2001
+ return record;
2002
+ });
1830
2003
  }
1831
2004
 
1832
2005
  case 'NAPTR': {
1833
2006
  // name authority pointer records `dnsPromises.resolveNaptr()`
1834
- return result.answers.map((a) => a.data);
2007
+ // Node.js v24+ adds 'type' property to NAPTR records (with undefined value)
2008
+ return result.answers.map((a) => {
2009
+ const record = { ...a.data };
2010
+ // Add type property for Node.js v24+ compatibility
2011
+ // Note: Node.js v24 sets type to undefined for NAPTR records
2012
+ if (NODE_MAJOR_VERSION >= 24) {
2013
+ record.type = undefined;
2014
+ }
2015
+
2016
+ return record;
2017
+ });
1835
2018
  }
1836
2019
 
1837
2020
  case 'NS': {
@@ -1846,15 +2029,25 @@ class Tangerine extends dns.promises.Resolver {
1846
2029
 
1847
2030
  case 'SOA': {
1848
2031
  // start of authority records `dnsPromises.resolveSoa()`
1849
- const answers = result.answers.map((a) => ({
1850
- nsname: a.data.mname,
1851
- hostmaster: a.data.rname,
1852
- serial: a.data.serial,
1853
- refresh: a.data.refresh,
1854
- retry: a.data.retry,
1855
- expire: a.data.expire,
1856
- minttl: a.data.minimum
1857
- }));
2032
+ // Node.js v24+ adds 'type' property to SOA records (with undefined value)
2033
+ const answers = result.answers.map((a) => {
2034
+ const record = {
2035
+ nsname: a.data.mname,
2036
+ hostmaster: a.data.rname,
2037
+ serial: a.data.serial,
2038
+ refresh: a.data.refresh,
2039
+ retry: a.data.retry,
2040
+ expire: a.data.expire,
2041
+ minttl: a.data.minimum
2042
+ };
2043
+ // Add type property for Node.js v24+ compatibility
2044
+ // Note: Node.js v24 sets type to undefined for SOA records
2045
+ if (NODE_MAJOR_VERSION >= 24) {
2046
+ record.type = undefined;
2047
+ }
2048
+
2049
+ return record;
2050
+ });
1858
2051
  //
1859
2052
  // NOTE: probably should just return answers[0] for consistency (?)
1860
2053
  //
@@ -1863,12 +2056,21 @@ class Tangerine extends dns.promises.Resolver {
1863
2056
 
1864
2057
  case 'SRV': {
1865
2058
  // service records `dnsPromises.resolveSrv()`
1866
- return result.answers.map((a) => ({
1867
- name: a.data.target,
1868
- port: a.data.port,
1869
- priority: a.data.priority,
1870
- weight: a.data.weight
1871
- }));
2059
+ // Node.js v24+ adds 'type' property to SRV records
2060
+ return result.answers.map((a) => {
2061
+ const record = {
2062
+ name: a.data.target,
2063
+ port: a.data.port,
2064
+ priority: a.data.priority,
2065
+ weight: a.data.weight
2066
+ };
2067
+ // Add type property for Node.js v24+ compatibility
2068
+ if (NODE_MAJOR_VERSION >= 24) {
2069
+ record.type = 'SRV';
2070
+ }
2071
+
2072
+ return record;
2073
+ });
1872
2074
  }
1873
2075
 
1874
2076
  case 'TXT': {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tangerine",
3
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": "1.5.9",
4
+ "version": "2.0.0",
5
5
  "author": "Forward Email (https://forwardemail.net)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/forwardemail/nodejs-dns-over-https-tangerine/issues"
@@ -10,7 +10,6 @@
10
10
  "Forward Email (https://forwardemail.net)"
11
11
  ],
12
12
  "dependencies": {
13
- "@ungap/structured-clone": "^1.2.0",
14
13
  "auto-bind": "4",
15
14
  "dns-packet": "^5.6.1",
16
15
  "dohdec": "^5.0.3",
@@ -57,7 +56,7 @@
57
56
  "xo": "^0.58.0"
58
57
  },
59
58
  "engines": {
60
- "node": ">=16"
59
+ "node": ">=18"
61
60
  },
62
61
  "files": [
63
62
  "index.js"