propgate docs

@propgate/dnsThe resolver

The resolver

query sends one DNS message and returns what happened. It is built on node:dgram and node:net directly rather than node:dns; see the overview for why that trade was made.

import { query, RecordType } from "@propgate/dns";

const outcome = await query({
  target: { address: "8.8.8.8", port: 53 },
  name: "example.com",
  type: RecordType.A,
});

if (outcome.status === "answered") {
  console.log(outcome.message.answers.length, "records over", outcome.transport);
}

Outcomes are values, not throws

A timeout, a refusal, and a mangled response are observations about a domain's DNS, not exceptional conditions in your program. query returns a discriminated union on status rather than throwing, because a single catch around a DNS call cannot tell "the server was slow" from "the record does not exist". Collapsing those two is exactly how a resolver ends up reporting "not found" for a domain that was merely unlucky this millisecond.

Status

Carries

Means

answered

message, transport, retriedOverTcp, elapsedMs

A decoded response arrived.

timeout

transport, timeoutMs, elapsedMs, retriedOverTcp

Nothing arrived before the deadline.

unreachable

transport, code, detail, elapsedMs

Connection refused, host unreachable, network down.

malformed

transport, reason, offset, detail, elapsedMs

Bytes arrived and did not decode as a DNS message.

truncated

message, transport ("udp"), elapsedMs

The UDP answer had TC set and retryOverTcp: false asked not to chase it.

import { query, RecordType } from "@propgate/dns";

const outcome = await query({
  target: { address: "8.8.8.8", port: 53 },
  name: "example.com",
  type: RecordType.A,
});

// Every branch is something a caller can act on. A single catch around this
// would have to guess which of these five actually happened.
switch (outcome.status) {
  case "answered":
    console.log("answered over", outcome.transport, "in", outcome.elapsedMs, "ms");
    break;
  case "timeout":
    console.log(outcome.retriedOverTcp ? "TCP retry never came back" : "no answer in time");
    break;
  case "unreachable":
    console.log(outcome.code, outcome.detail);
    break;
  case "malformed":
    console.log("could not decode:", outcome.reason, "at byte", outcome.offset);
    break;
  case "truncated":
    console.log("truncated over UDP, not retried:", outcome.message.flags.tc);
    break;
}

The one field worth pausing on is retriedOverTcp on timeout. It is only knowable at the moment of the retry: a bare TCP timeout means the server might be dead, but a TCP timeout that follows the same server already answering over UDP means the server is alive and something between you and it is eating TCP specifically. Without carrying that distinction forward, both cases look identical to a caller, which is the whole reason TCP_SILENTLY_BLOCKED sat unemitted before this field existed.

Everything is port-aware

Addresses are { address, port, transport }, and port is never assumed to be 53:

import { query, RecordType } from "@propgate/dns";

// Nothing here assumes port 53. A resolver container, a split-horizon test
// rig, or a nameserver running on an alternate port is the same call.
const outcome = await query({
  target: { address: "127.0.0.1", port: 8053, transport: "udp" },
  name: "example.com",
  type: RecordType.A,
});

That is not a convenience for tests. Root hints are injectable through ResolverOptions, and a glue record carries an address but no port. A resolver that hardcodes 53 anywhere in the delegation-following path breaks the moment it is pointed at a fixture tier serving real port 53 on non-default loopback addresses, and hardcoding a high port instead breaks production.

Truncation and the TCP fallback

By default, a truncated UDP answer is retried over TCP automatically, which is required behaviour for a resolver to see a 4096-bit DKIM key at all. Pass retryOverTcp: false to observe the TC bit instead of resolving past it:

import { query, RecordType } from "@propgate/dns";

// Omitting ednsBufferSize sends no OPT record at all, which caps the answer
// at 512 bytes by RFC 1035 — the only way to drive truncation from the
// client rather than by tuning the server.
const truncated = await query({
  target: { address: "198.51.100.1", port: 53 },
  name: "big-key._domainkey.example.com",
  type: RecordType.TXT,
  retryOverTcp: false,
});

if (truncated.status === "truncated") {
  console.log("TC bit set:", truncated.message.flags.tc);
}

Omitting ednsBufferSize entirely (rather than passing even a small value) is what sends no OPT record and caps the response at 512 bytes by RFC 1035. It is the only lever that drives truncation from the client side rather than by tuning what the server sends.

A swallowed TCP retry, where the server answers over UDP, asks for TCP, and the TCP connection then produces nothing, is not a dead server. It is a middlebox blocking TCP/53, and it is exactly what retriedOverTcp exists to distinguish from an ordinary timeout:

import { DiagnosisCode, query, RecordType } from "@propgate/dns";

// By default a truncated UDP answer is retried over TCP automatically.
const outcome = await query({
  target: { address: "198.51.100.1", port: 53 },
  name: "big-key._domainkey.example.com",
  type: RecordType.TXT,
});

// retriedOverTcp on a timeout means the *retry* is what timed out, not the
// first exchange — this server already answered over UDP and asked for TCP,
// which means it is alive. A bare TCP timeout can't tell you that; this can.
// It is exactly the shape the evaluators report as TCP_SILENTLY_BLOCKED.
if (outcome.status === "timeout" && outcome.retriedOverTcp) {
  console.log(DiagnosisCode.TCP_SILENTLY_BLOCKED, "— alive over UDP, silent over TCP");
}

To everyone else this looks like an intermittent outage: the record is fine, the server is fine, and a large DKIM key simply never arrives. See the evaluators for where this reasoning is built in rather than left to the caller.

Next

The evaluators build the six semantic checks on top of this: runChecks, a DomainProfile, and one subsection per check kind.