Repository: denoland/deno
Pull request: fix(ext/node): handle "ttl" option in "dns" module

Closes #27669

Changed files:
filename: ext/net/01_net.js
patch: @@ -35,6 +35,7 @@ import {
 const UDP_DGRAM_MAXSIZE = 65507;
 
 const {
+  ArrayPrototypeMap,
   Error,
   Number,
   NumberIsNaN,
@@ -78,12 +79,13 @@ async function resolveDns(query, recordType, options) {
   }
 
   try {
-    return await op_dns_resolve({
+    const res = await op_dns_resolve({
       cancelRid,
       query,
       recordType,
       options,
     });
+    return ArrayPrototypeMap(res, (recordWithTtl) => recordWithTtl.data);
   } finally {
     if (options?.signal) {
       options.signal[abortSignal.remove](abortHandler);

filename: ext/net/ops.rs
patch: @@ -788,7 +788,7 @@ pub fn op_net_accept_vsock() -> Result<(), NetError> {
 
 #[derive(Serialize, Eq, PartialEq, Debug)]
 #[serde(untagged)]
-pub enum DnsReturnRecord {
+pub enum DnsRecordData {
   A(String),
   Aaaa(String),
   Aname(String),
@@ -830,6 +830,13 @@ pub enum DnsReturnRecord {
   Txt(Vec<String>),
 }
 
+#[derive(Serialize, Eq, PartialEq, Debug)]
+#[serde()]
+pub struct DnsRecordWithTtl {
+  pub data: DnsRecordData,
+  pub ttl: u32,
+}
+
 #[derive(Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResolveAddrArgs {
@@ -862,7 +869,7 @@ pub struct NameServer {
 pub async fn op_dns_resolve<NP>(
   state: Rc<RefCell<OpState>>,
   #[serde] args: ResolveAddrArgs,
-) -> Result<Vec<DnsReturnRecord>, NetError>
+) -> Result<Vec<DnsRecordWithTtl>, NetError>
 where
   NP: NetPermissions + 'static,
 {
@@ -947,9 +954,18 @@ where
       }
       _ => NetError::Dns(e),
     })?
+    .records()
     .iter()
-    .filter_map(|rdata| rdata_to_return_record(record_type)(rdata).transpose())
-    .collect::<Result<Vec<DnsReturnRecord>, NetError>>()
+    .filter_map(|rec| {
+      let r = format_rdata(record_type)(rec.data()).transpose();
+      r.map(|maybe_data| {
+        maybe_data.map(|data| DnsRecordWithTtl {
+          data,
+          ttl: rec.ttl(),
+        })
+      })
+    })
+    .collect::<Result<Vec<DnsRecordWithTtl>, NetError>>()
 }
 
 #[op2(fast)]
@@ -992,22 +1008,22 @@ pub fn op_set_keepalive_inner(
   resource.set_keepalive(keepalive).map_err(NetError::Map)
 }
 
-fn rdata_to_return_record(
+fn format_rdata(
   ty: RecordType,
-) -> impl Fn(&RData) -> Result<Option<DnsReturnRecord>, NetError> {
+) -> impl Fn(&RData) -> Result<Option<DnsRecordData>, NetError> {
   use RecordType::*;
-  move |r: &RData| -> Result<Option<DnsReturnRecord>, NetError> {
+  move |r: &RData| -> Result<Option<DnsRecordData>, NetError> {
     let record = match ty {
-      A => r.as_a().map(ToString::to_string).map(DnsReturnRecord::A),
+      A => r.as_a().map(ToString::to_string).map(DnsRecordData::A),
       AAAA => r
         .as_aaaa()
         .map(ToString::to_string)
-        .map(DnsReturnRecord::Aaaa),
+        .map(DnsRecordData::Aaaa),
       ANAME => r
         .as_aname()
         .map(ToString::to_string)
-        .map(DnsReturnRecord::Aname),
-      CAA => r.as_caa().map(|caa| DnsReturnRecord::Caa {
+        .map(DnsRecordData::Aname),
+      CAA => r.as_caa().map(|caa| DnsRecordData::Caa {
         critical: caa.issuer_critical(),
         tag: caa.tag().to_string(),
         value: match caa.value() {
@@ -1034,25 +1050,22 @@ fn rdata_to_return_record(
       CNAME => r
         .as_cname()
         .map(ToString::to_string)
-        .map(DnsReturnRecord::Cname),
-      MX => r.as_mx().map(|mx| DnsReturnRecord::Mx {
+        .map(DnsRecordData::Cname),
+      MX => r.as_mx().map(|mx| DnsRecordData::Mx {
         preference: mx.preference(),
         exchange: mx.exchange().to_string(),
       }),
-      NAPTR => r.as_naptr().map(|naptr| DnsReturnRecord::Naptr {
+      NAPTR => r.as_naptr().map(|naptr| DnsRecordData::Naptr {
         order: naptr.order(),
         preference: naptr.preference(),
         flags: String::from_utf8(naptr.flags().to_vec()).unwrap(),
         services: String::from_utf8(naptr.services().to_vec()).unwrap(),
         regexp: String::from_utf8(naptr.regexp().to_vec()).unwrap(),
         replacement: naptr.replacement().to_string(),
       }),
-      NS => r.as_ns().map(ToString::to_string).map(DnsReturnRecord::Ns),
-      PTR => r
-        .as_ptr()
-        .map(ToString::to_string)
-        .map(DnsReturnRecord::Ptr),
-      SOA => r.as_soa().map(|soa| DnsReturnRecord::Soa {
+      NS => r.as_ns().map(ToString::to_string).map(DnsRecordData::Ns),
+      PTR => r.as_ptr().map(ToString::to_string).map(DnsRecordData::Ptr),
+      SOA => r.as_soa().map(|soa| DnsRecordData::Soa {
         mname: soa.mname().to_string(),
         rname: soa.rname().to_string(),
         serial: soa.serial(),
@@ -1061,7 +1074,7 @@ fn rdata_to_return_record(
         expire: soa.expire(),
         minimum: soa.minimum(),
       }),
-      SRV => r.as_srv().map(|srv| DnsReturnRecord::Srv {
+      SRV => r.as_srv().map(|srv| DnsRecordData::Srv {
         priority: srv.priority(),
         weight: srv.weight(),
         port: srv.port(),
@@ -1075,7 +1088,7 @@ fn rdata_to_return_record(
             bytes.iter().map(|&b| b as char).collect::<String>()
           })
           .collect();
-        DnsReturnRecord::Txt(texts)
+        DnsRecordData::Txt(texts)
       }),
       _ => return Err(NetError::UnsupportedRecordType),
     };
@@ -1118,45 +1131,45 @@ mod tests {
 
   #[test]
   fn rdata_to_return_record_a() {
-    let func = rdata_to_return_record(RecordType::A);
+    let func = format_rdata(RecordType::A);
     let rdata = RData::A(A(Ipv4Addr::new(127, 0, 0, 1)));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::A("127.0.0.1".to_string()))
+      Some(DnsRecordData::A("127.0.0.1".to_string()))
     );
   }
 
   #[test]
   fn rdata_to_return_record_aaaa() {
-    let func = rdata_to_return_record(RecordType::AAAA);
+    let func = format_rdata(RecordType::AAAA);
     let rdata = RData::AAAA(AAAA(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Aaaa("::1".to_string()))
+      Some(DnsRecordData::Aaaa("::1".to_string()))
     );
   }
 
   #[test]
   fn rdata_to_return_record_aname() {
-    let func = rdata_to_return_record(RecordType::ANAME);
+    let func = format_rdata(RecordType::ANAME);
     let rdata = RData::ANAME(ANAME(Name::new()));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Aname("".to_string()))
+      Some(DnsRecordData::Aname("".to_string()))
     );
   }
 
   #[test]
   fn rdata_to_return_record_caa() {
-    let func = rdata_to_return_record(RecordType::CAA);
+    let func = format_rdata(RecordType::CAA);
     let rdata = RData::CAA(CAA::new_issue(
       false,
       Some(Name::parse("example.com", None).unwrap()),
       vec![KeyValue::new("account", "123456")],
     ));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Caa {
+      Some(DnsRecordData::Caa {
         critical: false,
         tag: "issue".to_string(),
         value: "example.com; account=123456".to_string(),
@@ -1166,21 +1179,21 @@ mod tests {
 
   #[test]
   fn rdata_to_return_record_cname() {
-    let func = rdata_to_return_record(RecordType::CNAME);
+    let func = format_rdata(RecordType::CNAME);
     let rdata = RData::CNAME(CNAME(Name::new()));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Cname("".to_string()))
+      Some(DnsRecordData::Cname("".to_string()))
     );
   }
 
   #[test]
   fn rdata_to_return_record_mx() {
-    let func = rdata_to_return_record(RecordType::MX);
+    let func = format_rdata(RecordType::MX);
     let rdata = RData::MX(MX::new(10, Name::new()));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Mx {
+      Some(DnsRecordData::Mx {
         preference: 10,
         exchange: "".to_string()
       })
@@ -1189,7 +1202,7 @@ mod tests {
 
   #[test]
   fn rdata_to_return_record_naptr() {
-    let func = rdata_to_return_record(RecordType::NAPTR);
+    let func = format_rdata(RecordType::NAPTR);
     let rdata = RData::NAPTR(NAPTR::new(
       1,
       2,
@@ -1200,7 +1213,7 @@ mod tests {
     ));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Naptr {
+      Some(DnsRecordData::Naptr {
         order: 1,
         preference: 2,
         flags: "".to_string(),
@@ -1213,27 +1226,27 @@ mod tests {
 
   #[test]
   fn rdata_to_return_record_ns() {
-    let func = rdata_to_return_record(RecordType::NS);
+    let func = format_rdata(RecordType::NS);
     let rdata = RData::NS(NS(Name::new()));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Ns("".to_string()))
+      Some(DnsRecordData::Ns("".to_string()))
     );
   }
 
   #[test]
   fn rdata_to_return_record_ptr() {
-    let func = rdata_to_return_record(RecordType::PTR);
+    let func = format_rdata(RecordType::PTR);
     let rdata = RData::PTR(PTR(Name::new()));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Ptr("".to_string()))
+      Some(DnsRecordData::Ptr("".to_string()))
     );
   }
 
   #[test]
   fn rdata_to_return_record_soa() {
-    let func = rdata_to_return_record(RecordType::SOA);
+    let func = format_rdata(RecordType::SOA);
     let rdata = RData::SOA(SOA::new(
       Name::new(),
       Name::new(),
@@ -1245,7 +1258,7 @@ mod tests {
     ));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Soa {
+      Some(DnsRecordData::Soa {
         mname: "".to_string(),
         rname: "".to_string(),
         serial: 0,
@@ -1259,11 +1272,11 @@ mod tests {
 
   #[test]
   fn rdata_to_return_record_srv() {
-    let func = rdata_to_return_record(RecordType::SRV);
+    let func = format_rdata(RecordType::SRV);
     let rdata = RData::SRV(SRV::new(1, 2, 3, Name::new()));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Srv {
+      Some(DnsRecordData::Srv {
         priority: 1,
         weight: 2,
         port: 3,
@@ -1274,7 +1287,7 @@ mod tests {
 
   #[test]
   fn rdata_to_return_record_txt() {
-    let func = rdata_to_return_record(RecordType::TXT);
+    let func = format_rdata(RecordType::TXT);
     let rdata = RData::TXT(TXT::from_bytes(vec![
       "foo".as_bytes(),
       "bar".as_bytes(),
@@ -1283,7 +1296,7 @@ mod tests {
     ]));
     assert_eq!(
       func(&rdata).unwrap(),
-      Some(DnsReturnRecord::Txt(vec![
+      Some(DnsRecordData::Txt(vec![
         "foo".to_string(),
         "bar".to_string(),
         "£".to_string(),

filename: ext/node/polyfills/dns.ts
patch: @@ -93,7 +93,6 @@ import {
   QueryReqWrap,
 } from "ext:deno_node/internal_binding/cares_wrap.ts";
 import { domainToASCII } from "ext:deno_node/internal/idna.ts";
-import { notImplemented } from "ext:deno_node/_utils.ts";
 
 function onlookup(
   this: GetAddrInfoReqWrap,
@@ -345,11 +344,6 @@ function resolver(bindingName: keyof ChannelWrapQuery) {
     req.callback = callback as ResolveCallback;
     req.hostname = name;
     req.oncomplete = onresolve;
-
-    if (options && (options as ResolveOptions).ttl) {
-      notImplemented("dns.resolve* with ttl option");
-    }
-
     req.ttl = !!(options && (options as ResolveOptions).ttl);
 
     const err = this._handle[bindingName](req, domainToASCII(name));

filename: ext/node/polyfills/internal_binding/cares_wrap.ts
patch: @@ -37,6 +37,7 @@ import {
 import { ares_strerror } from "ext:deno_node/internal_binding/ares.ts";
 import { notImplemented } from "ext:deno_node/_utils.ts";
 import {
+  op_dns_resolve,
   op_net_get_ips_from_perm_token,
   op_node_getaddrinfo,
 } from "ext:core/ops";
@@ -198,9 +199,7 @@ export class ChannelWrap extends AsyncWrap implements ChannelWrapQuery {
     this.#tries = tries;
   }
 
-  async #query(query: string, recordType: Deno.RecordType) {
-    // TODO(@bartlomieju): TTL logic.
-
+  async #query(query: string, recordType: Deno.RecordType, ttl?: boolean) {
     let code: number;
     let ret: Awaited<ReturnType<typeof Deno.resolveDns>>;
 
@@ -217,14 +216,15 @@ export class ChannelWrap extends AsyncWrap implements ChannelWrapQuery {
           query,
           recordType,
           resolveOptions,
+          ttl,
         ));
 
         if (code === 0 || code === codeMap.get("EAI_NODATA")!) {
           break;
         }
       }
     } else {
-      ({ code, ret } = await this.#resolve(query, recordType));
+      ({ code, ret } = await this.#resolve(query, recordType, null, ttl));
     }
 
     return { code: code!, ret: ret! };
@@ -234,15 +234,26 @@ export class ChannelWrap extends AsyncWrap implements ChannelWrapQuery {
     query: string,
     recordType: Deno.RecordType,
     resolveOptions?: Deno.ResolveDnsOptions,
+    ttl?: boolean,
   ): Promise<{
     code: number;
-    ret: Awaited<ReturnType<typeof Deno.resolveDns>>;
+    // deno-lint-ignore no-explicit-any
+    ret: any[];
   }> {
-    let ret: Awaited<ReturnType<typeof Deno.resolveDns>> = [];
+    let ret = [];
     let code = 0;
 
     try {
-      ret = await Deno.resolveDns(query, recordType, resolveOptions);
+      const res = await op_dns_resolve({
+        query,
+        recordType,
+        options: resolveOptions,
+      });
+      if (ttl) {
+        ret = res;
+      } else {
+        ret = res.map((recordWithTtl) => recordWithTtl.data);
+      }
     } catch (e) {
       if (e instanceof Deno.errors.NotFound) {
         code = codeMap.get("EAI_NODATA")!;
@@ -363,18 +374,34 @@ export class ChannelWrap extends AsyncWrap implements ChannelWrapQuery {
   }
 
   queryA(req: QueryReqWrap, name: string): number {
-    this.#query(name, "A").then(({ code, ret }) => {
-      req.oncomplete(code, ret);
+    this.#query(name, "A", req.ttl).then(({ code, ret }) => {
+      let recordsWithTtl;
+      if (req.ttl) {
+        recordsWithTtl = (ret as Deno.RecordWithTtl[]).map((val) => ({
+          address: val?.data,
+          ttl: val?.ttl,
+        }));
+      }
+
+      req.oncomplete(code, recordsWithTtl ?? ret);
     });
 
     return 0;
   }
 
   queryAaaa(req: QueryReqWrap, name: string): number {
-    this.#query(name, "AAAA").then(({ code, ret }) => {
-      const records = (ret as string[]).map((record) => compressIPv6(record));
+    this.#query(name, "AAAA", req.ttl).then(({ code, ret }) => {
+      let recordsWithTtl;
+      if (req.ttl) {
+        recordsWithTtl = (ret as Deno.RecordWithTtl[]).map((val) => ({
+          address: compressIPv6(val?.data as string),
+          ttl: val?.ttl,
+        }));
+      } else {
+        ret = (ret as string[]).map((record) => compressIPv6(record));
+      }
 
-      req.oncomplete(code, records);
+      req.oncomplete(code, recordsWithTtl ?? ret);
     });
 
     return 0;

filename: tests/unit_node/net_test.ts
patch: @@ -1,11 +1,11 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
 
-// deno-lint-ignore-file no-console
-
 import * as net from "node:net";
 import { assert, assertEquals } from "@std/assert";
 import * as path from "@std/path";
 import * as http from "node:http";
+import * as dns from "node:dns";
+import console from "node:console";
 
 Deno.test("[node/net] close event emits after error event - when host is not found", async () => {
   const socket = net.createConnection(27009, "doesnotexist");
@@ -247,3 +247,30 @@ Deno.test("[node/net] net.Server can listen on the same port immediately after i
   });
   await serverClosed.promise;
 });
+
+Deno.test("dns.resolve with ttl", async () => {
+  const d1 = Promise.withResolvers();
+  dns.resolve4("www.example.com", { ttl: true }, (err, addresses) => {
+    if (err) {
+      d1.reject(err);
+    } else {
+      d1.resolve(addresses);
+    }
+  });
+  // deno-lint-ignore no-explicit-any
+  const ret1 = await d1.promise as any[];
+  assert(ret1.length > 0);
+  assert(typeof ret1[0].ttl === "number");
+
+  const d2 = Promise.withResolvers();
+  dns.resolve4("www.example.com", (err, addresses) => {
+    if (err) {
+      d2.reject(err);
+    } else {
+      d2.resolve(addresses);
+    }
+  });
+  const ret2 = await d2.promise as string[];
+  assert(ret2.length > 0);
+  assert(typeof ret2[0] === "string");
+});