node:dns: match Node.js argument validation in lookup()
dns.lookup()'s argument validation diverges from Node.js in three ways, all of which let a caller bug through quietly.
Repro
import dns from "node:dns";
const show = async fn => {
try {
return "resolved: " + JSON.stringify(await fn());
} catch (e) {
return `${e.name}[${e.code}] ${e.message.slice(0, 80)}`;
}
};
console.log("1.", await show(() => dns.promises.lookup("localhost", { family: "IPv4" })));
console.log("2.", await show(() => dns.promises.lookup("localhost", { all: 1 })));
console.log("3.", await show(() => dns.promises.lookup("l".repeat(300))));
| Node.js v26.3.0 | Bun 1.4.0 / main | |
|---|---|---|
| 1 | TypeError[ERR_INVALID_ARG_VALUE] The property 'options.family' must be one of: 0, 4, 6. Received 'IPv4' | resolved: {"address":"127.0.0.1","family":4} |
| 2 | TypeError[ERR_INVALID_ARG_TYPE] The "options.all" property must be of type boolean. Received type number (1) | TypeError[ERR_INVALID_ARG_TYPE] The "undefined" argument must be of type boolean. Received type number (1) |
| 3 | Error[EINVAL] getaddrinfo EINVAL llll... | DNSException[ENOTFOUND] getaddrinfo ENOTFOUND |
- A string
familythat Node.js hard-rejects resolves successfully, so a config parser that relies on the throw to reject a baddns.lookupoption object silently proceeds. The "undefined" argument must be...names no argument at all, and does not match what Node.js prints.- An impossible (over-long) hostname gets the same code as a legitimately missing one, so retry/fallback logic treats a caller bug as a transient resolution miss.
Cause
validateFamilyOption()mapped"IPv4"/"IPv6"to4/6before validating. That mapping belongs toBun.dns.lookup, and to Node.js's callbackdns.lookup(which does accept those two spellings), butdns.promises.lookuprejects them. Bun shared one validator between both entry points.validateBoolean(all)/validateBoolean(verbatim)/validateNumber(flags)were called without theirnameargument, so the message interpolatedundefined.- Nothing checked the hostname length.
getaddrinfoencodes the name into a 256 byte buffer (IDNA ASCII + NUL), so Node.js fails a longer one withEINVALbefore any query; Bun handed it to the resolver and surfaced itsENOTFOUND.
Fix
In src/js/node/dns.ts:
validateFamilyOption()takes anallowFamilyStringsflag: the callbacklookup()passestrue,promises.lookup()passesfalse. Everything else still goes throughvalidateFamily(), which now throws Node'smust be one of: 0, 4, 6.validateBoolean/validateNumber/validateOneOfget"options.all","options.verbatim","options.hints","options.order"(and"dnsOrder"forsetDefaultResultOrder).- An ASCII hostname over 255 characters short-circuits to
getaddrinfo EINVAL <hostname>(code: "EINVAL",errno: -22), delivered on the next tick for the callback form and as a rejection for the promise form, exactly like Node'sdnsException(UV_EINVAL, ...). A 255 character hostname still reaches the resolver. The guard is ASCII-only on purpose: an ASCII hostname is its own IDNA encoding, whereas"𝕏".repeat(128)has a.lengthof 256 but encodes to 135 bytes, which Node resolves normally. Non-ASCII hostnames keep today's behavior rather than paying for a punycode conversion on every lookup. - A
nulloption now means unset, as in Node.js.{ all: null },{ verbatim: null }and{ hints: null }used to throw. dns.lookupno longer carries thepromisify.customsymbol and getscustomPromisifyArgs = ["address", "family"], like Node. Otherwiseutil.promisify(dns.lookup)(which Bun aliased straight todnsPromises.lookup) would have inherited the stricterfamilyvalidation, where Node promisifies the callback implementation and still accepts"IPv4".
Two supporting changes:
src/jsc/bindings/ErrorCode.cpp: thevalidateOneOfoverload ofERR_INVALID_ARG_VALUEhardcodedThe argument '...', while Node.js sayspropertywhenever the name contains a dot. That also fixes the message foroptions.serialization,options.microtaskMode,options.mode,options.execution,options.typeandoptions.backpressure, sotest-child-process-advanced-serialization.jsnow matches upstream's expectation instead of Bun's old wording.src/js/internal/promisify.ts: build thecustomPromisifyArgsobject only when the callback passed more than one value, as Node does, soutil.promisify(dns.lookup)(host, { all: true })stays an array. The other users (fs.read/readv/write/writev,crypto.generateKeyPair) always pass two values.
Bun.dns.lookup() is unchanged and still accepts family: "IPv4".
Verification
test/js/node/dns/dns-lookup-validation.test.ts (new, hermetic: hosts-file lookups and pure validation, no network) covers every case above plus the boundaries. 19 tests, 16 of which fail on the released binary. It lives beside node-dns.test.js rather than inside it because that file is network-bound.
Node's own suite still passes, with two assertions restored to upstream's text:
test-dns-lookup.js (options.family, options.hints, options.all, options.verbatim, options.order)
test-dns-set-default-order.js
test-dns-lookup-promises-options-deprecated.js
test-dns-default-order-{ipv4,ipv6,verbatim}.js
test-child-process-advanced-serialization.js
test-console-tty-colors.js
test-http-agent-scheduling.js
test-util-promisify.js, test-util-promisify-custom-names.mjs
test-fs-read-promises-optional-params.js, test-fs-readv-promisify.js
test-crypto-keygen-promisify.js
node-dns.test.js and test/js/bun/dns/resolve-dns.test.ts report the same pass/fail counts before and after (the failures are the network-bound tests, which this sandbox cannot run).
Out of scope, still different from Node.js: dns.promises.lookup's resolver errors are named DNSException and omit the hostname from the message (getaddrinfo ENOTFOUND vs Node's getaddrinfo ENOTFOUND example.com). That is error decoration in the promise path, not argument validation.