dns2: a DNS server and client in pure JavaScript with no dependencies
:globe_with_meridians: DNS Server and Client Implementation in Pure JavaScript with no dependencies.
At a glance
- What is it?
- dns2 is a DNS implementation for Node that ships both a resolver and a server, with UDP, TCP and DNS over HTTPS support and no runtime dependencies. Its most interesting work is in the failure path, where a malformed packet produces a structured error report and, optionally, an RFC 8914 Extended DNS Error instead of a bare FORMERR.
- Who is it for?
- Adopt dns2 if you need a DNS resolver or an authoritative server inside a Node service and you would rather not take on a native dependency, and if you care about telling a client why a query failed rather than just that it failed. Do not adopt it as a replacement for a recursive resolver in production without reading the deployment details, and do not expect it to compete with a mature server on protocol breadth.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 89 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The whole package is JavaScript with an empty dependency list
dns2 is described as a DNS server and client implementation in pure JavaScript with no dependencies, and the manifest backs that up: the package has no runtime dependencies section at all, and the keywords list is a single entry, dns.
That constraint shapes everything. There is no native addon to compile, no binding to a system library, no version of libuv to match. The package ships plain JavaScript with a hand written TypeScript declaration file, and the exports field points CommonJS at index.js and ES modules at index.mjs, with types for both. The homepage is the npmjs.org page for dns2, and the licence is MIT.
The feature list is short. It has a server and a client. It supports a lot of record types. It is described as extremely lightweight. And it supports DNS over UDP, TCP and HTTPS.
The repository layout shows a codebase organised by role rather than by file type. There is a lib/ directory, a client/ directory, a server/ directory, a packet.js at the root, both index.js and index.mjs, and a ts/ folder holding the type declarations. The example/ folder has client and server subfolders, and the npm scripts run them directly, with separate entries for a UDP server, a TCP server, a DoH server, and a DoH client, among others.
The recent release history shows active development. v3.1.1 shipped on 2026-07-26, and both v3.0.0 and v2.4.0 shipped on 2026-05-26, which suggests a major version bump and a feature release on the same day. The last push was on 2026-07-26 and the repository is not archived.
The client queries every name server in parallel and takes the first answer
Installing the package is the usual npm line:
$ npm install dns2The client is created with an options object and then used through a high level DNS class. The options are commented in the README: nameServers is an array of DNS server IPs, defaulting to Google and 114dns, port is the DNS server port, recursive is the Recursion Desired flag and defaults to true, and timeout is a per name server timeout in milliseconds.
The call itself is an await on a method that takes a domain:
(async () => {
const result = await dns.resolveA('google.com');
console.log(result.answers);
})();The convenience methods are named after the record type, and each one returns the answer fields that matter for that type. resolveA and resolveAAAA give you an address. resolveMX gives exchange and priority. resolveCNAME and resolvePTR give a domain. resolveSOA gives primary, admin, serial, refresh, retry, expiration and minimum. resolveDNSKEY gives publicKey and algorithm, and resolveRRSIG varies. For a record type not in that list, you call dns.resolve with the type string directly.
The parallelism rule is the operationally interesting part. Every name server is queried in parallel and the first successful reply wins. If all of them fail, the rejection names each server and its reason. So a lookup against a default pair of resolvers does not wait for a timeout on one of them before trying the other.
A packet that cannot be decoded throws, and one that partially decodes does not
The README spends a lot of space on what happens when a packet is wrong, and the design here is more careful than most.
A message that cannot be decoded at all throws a Packet.DecodeError. That is the case where the message is shorter than a 12-octet header, or is not a Buffer:
try {
Packet.parse(buffer);
} catch (err) {
// "message is 7 octets, too short for the 12-octet header (RFC 1035 §4.1.1)"
console.error(err.message);
}Once the header decodes, the behaviour changes. A malformed record no longer disappears silently, and it is not thrown either. Records that cannot be decoded are dropped, on the reasoning that a half-populated record would be worse than none, and the reason is reported on packet.errors:
const packet = Packet.parse(buffer);
for (const err of packet.errors) {
console.error(err.message);
// "answers[0] at offset 12: TXT decode: character-string of 10 octets
// overruns RDATA (4 octets remaining)"
err.section; // 'questions' | 'answers' | 'authorities' | 'additionals'
err.index; // position within that section
err.offset; // octet offset in the message where the record started
err.recovered; // see below
}Each error carries the section it happened in, the index within that section, the octet offset where the record started, and a recovered flag. That flag distinguishes the two kinds of damage. When the damage is confined to one record's RDATA, recovered is true, because RDLENGTH says where the next record begins, so decoding continued and later records are intact. When the failure left the reader misaligned, which the README describes as a truncated message or a name that ran off the end, recovered is false, because nothing after that point can be located and decoding stopped there rather than emitting junk records.
packet.errors is empty for a clean parse, so checking its length is the test for whether anything went wrong, and comparing a section's length against its header count shows how much of the message survived.
The server can answer FORMERR or explain itself with Extended DNS Errors
Servers surface the same information, and there are two ways to act on it. A query that cannot be decoded at all raises a requestError event. A query that partially decodes reaches your handler with request.errors populated, which is enough to answer FORMERR if you want to:
dns2
.createServer({
udp: true,
handle: (request, send) => {
if (request.errors.length) {
const response = Packet.createResponseFromRequest(request);
response.header.rcode = Packet.RCODE.FORMERR;
return send(response);
}
// ...
},
})
.on('requestError', err => console.error('undecodable query:', err.message));The richer option is RFC 8914 Extended DNS Errors. The README makes the case for it plainly: FORMERR says malformed in four bits and nothing more, while Extended DNS Errors add an EDNS option carrying an INFO-CODE plus free-form text, which is where a decode reason belongs. Packet.EDE.INVALID_DATA, with the value 24, is the code for data that could not be interpreted.
Building that reply is one call, and the extra text can be assembled from the errors you already have:
const server = dns2.createServer({
udp: true,
handle: (request, send) => {
if (request.errors.length) {
return send(
Packet.createErrorResponseFromRequest(request, Packet.RCODE.FORMERR, {
infoCode: Packet.EDE.INVALID_DATA,
extraText: request.errors.map(e => e.message).join('; '),
}),
);
}
// ... normal handling
},
});A client reads the reason back off any response, by finding the EDNS record in the additionals section and looking for the EDE option inside it. Extended errors are additive in the sense that they annotate a response without changing its RCODE, and they appear on NOERROR responses too.
Three rules govern when the EDNS option is attached
The README lists three details that the error response builder handles, and they are the kind of edge cases that decide whether your error reporting works against real clients.
The first is that the option is attached only when the request carried an OPT record, following RFC 8914 section 3, since a client that did not signal EDNS cannot be sent EDNS options. A request too malformed to have a readable OPT gets a bare RCODE instead.
The second is that an OPT is added regardless when the RCODE exceeds 15, so that its high byte survives serialization. The reason given is direct: otherwise BADVERS would go out as NOERROR. This is a small detail that would otherwise silently downgrade an error signal.
The third is that extraText is truncated to Packet.EDE_MAX_TEXT, which is 256, because the reply still has to fit the negotiated UDP payload size. A long explanation that does not fit in the response is worth less than a short one that does.
The registry itself lives on the Packet object. Packet.EDE holds the full set of INFO-CODEs and Packet.EDE_NAME maps a received code back to its name, which is what makes the error text readable on the client side rather than a bare number.
Running the examples is the quickest way to see the three transports
The package scripts make the example code runnable, and between them they cover the three transports the README claims support for. On the server side there are entries for a UDP server, a TCP server and a DoH server, and on the client side there are entries for a DoH client, a UDP client, a TCP client, a client that talks to Google, and one that queries a UDP subnet. There is also a benchmark script that runs the UDP benchmark in the benchmark/ directory.
That set of scripts is worth more than it looks, because a DNS implementation has to behave the same over three different transports and the difference between them is where most of the bugs live. UDP is datagram sized and has no connection state, TCP is a length prefixed stream, and DNS over HTTPS carries the same message over HTTP.
The development tooling is lean and consistent with a zero dependency project. Tests run with the Node built in test runner, node --test, and coverage uses the same runner with the experimental test coverage flag. Linting is eslint and formatting is prettier, both through npx, with separate fix scripts. Type checking runs tsc over two configurations, one for the CommonJS declaration file and one for the ES module one, so both entry points are checked.
The README also links a section on SOA record lookup, noting that an SOA record contains the authoritative zone information for a domain and that it appears in the result when the authoritative name server returns it in the answer section.
Editorial conclusion
Adopt dns2 if you need a DNS resolver or an authoritative server inside a Node service and you would rather not take on a native dependency, and if you care about telling a client why a query failed rather than just that it failed. Do not adopt it as a replacement for a recursive resolver in production without reading the deployment details, and do not expect it to compete with a mature server on protocol breadth. Before you build on it, look at the packet error handling, because the Packet.errors array and the Extended DNS Error path are the parts that decide how your service behaves when it receives something malformed.
Frequently asked questions
How do I install and use the dns2 DNS client?
Run `npm install dns2`, create a client with `new dns2(options)`, then await a method such as `dns.resolveA('google.com')` and read the answers. The client uses UDP by default, queries every configured name server in parallel, and the first successful reply wins.
How does dns2 report a malformed DNS packet?
A message that cannot be decoded at all throws a Packet.DecodeError. A message whose header decodes but which contains a broken record drops that record and reports the reason on packet.errors, where each error carries the section, the index, the octet offset and a recovered flag.
What are Extended DNS Errors in dns2?
They are the RFC 8914 mechanism for carrying a reason alongside an error code, built with Packet.createErrorResponseFromRequest and Packet.EDE.INVALID_DATA for data that could not be interpreted. The option is only attached when the request carried an OPT record, and extraText is truncated to 256 characters.
Does dns2 support DNS over HTTPS?
Yes. The README lists DNS over UDP, TCP and HTTPS as supported, and the npm scripts include example servers and clients for each of the three transports, including a DoH server and a DoH client.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/lsongdev-node-dns)Was this useful?