mdns
js/net/mdns.ts
fino:net/mdns — Multicast DNS and DNS-SD discovery helpers.
Learn more:
- Multicast DNS: https://www.rfc-editor.org/rfc/rfc6762
- DNS-Based Service Discovery: https://www.rfc-editor.org/rfc/rfc6763
- DNS message format: https://www.rfc-editor.org/rfc/rfc1035
This module keeps mDNS separate from the unicast DNS resolver because mDNS has local-link trust rules, multicast sockets, cache semantics, and DNS-SD service lifecycles that do not belong in ordinary recursive DNS.
This implementation supports one-shot host resolution, DNS-SD browse events,
service resolution, and responder-backed publication. Multicast interface
selection is available through fino:net/socket; deterministic tests may
pass an explicit unicast server or bind address.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const addresses = await mdns.resolveHost('printer.local');
await mdns.close();
Types
type MdnsRecordType = 'A' | 'AAAA' | 'PTR' | 'SRV' | 'TXT'
DNS record types this module knows how to query on the local link.
A and AAAA carry IPv4 and IPv6 addresses, PTR enumerates DNS-SD
service instances for a service type, SRV carries the target host and port
of a service instance, and TXT carries its key/value metadata. These are
the string names accepted by Mdns.query; the module maps them to numeric
DNS record codes internally.
import { Mdns, type MdnsRecordType } from 'fino:net/mdns';
const mdns = new Mdns();
const rrtype: MdnsRecordType = 'AAAA';
const records = await mdns.query('printer.local', rrtype);
await mdns.close();
Interfaces
interface MdnsQueryOptions {
Tuning and destination options shared by every Mdns query operation.
All fields are optional. The defaults target the standard mDNS multicast
group on the link-local network, retransmit unanswered one-shot queries with
randomized backoff, and honor the per-instance answer cache. The socket and
interface fields (bindAddress, interfaceAddress, interfaceIndex) select
which interfaces participate in multicast membership; server overrides the
destination entirely and is primarily useful for deterministic tests or a
controlled unicast relay. continuous, resolve, and pollMs only affect
browse.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const controller = new AbortController();
const addresses = await mdns.resolveHost('printer.local', {
timeoutMs: 1500,
interfaceAddress: '192.168.1.10',
unicastResponse: true,
signal: controller.signal,
});
await mdns.close();
Properties
timeoutMs?: number
Query timeout in milliseconds. Defaults to 2500.
retryMinMs?: number
Minimum delay before retransmitting unanswered one-shot queries. Defaults to 250.
retryMaxMs?: number
Maximum delay before retransmitting unanswered one-shot queries. Defaults to 1000.
settleMs?: number
Additional milliseconds to collect answer bursts after the first packet. Defaults to 20.
bindAddress?: Address
Local UDP address to bind for the query or continuous browse socket.
interfaceAddress?: string
IPv4 interface address used for multicast membership and outbound packets.
interfaceIndex?: number
IPv6 interface index used for multicast membership and outbound packets.
unicastResponse?: boolean
Set the mDNS QU bit to request a unicast response when supported.
knownAnswers?: readonly DnsResourceRecord[]
Known answers to include in outgoing mDNS queries for responder suppression.
cache?: boolean
Use the instance cache for fresh answers. Defaults to true.
validateSource?: boolean
Validate response source addresses against local interface metadata.
localInterfaces?: readonly sock.NetworkInterface[]
Interface metadata used for source validation. Defaults to networkInterfaces().
server?: Address
Override destination for deterministic tests or controlled relays.
signal?: AbortSignal
Abort a long-running query or browse operation.
continuous?: boolean
Keep browse() alive and emit later up/down events.
resolve?: boolean
Resolve service metadata for browse events and emit updates when it changes.
pollMs?: number
Delay between continuous browse refresh queries. Defaults to 1000.
interface MdnsBrowseEvent {
A single service-lifetime change yielded by Mdns.browse.
One-shot browses emit only up events for each unique instance discovered.
A continuous browse also emits update when an instance's TTL or resolved
metadata changes and down when a goodbye (zero-TTL) record or TTL expiry
retires it. The service field is populated only when the browse ran with
resolve: true, in which case up/update events carry the resolved SRV,
TXT, and address data.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const controller = new AbortController();
for await (const event of mdns.browse('_http._tcp.local', {
continuous: true,
resolve: true,
signal: controller.signal,
})) {
if (event.type === 'down') console.log('gone:', event.name);
else console.log(event.type, event.name, event.service?.addresses);
}
Properties
type: 'up' | 'update' | 'down'
Event kind for service lifetime changes.
name: string
Service instance name, such as Printer._http._tcp.local.
serviceType: string
Browsed service type, such as _http._tcp.local.
interfaceIndex: number | null
Interface index when known.
service?: MdnsService
Resolved service metadata when browse runs with resolve: true.
interface MdnsService {
A DNS-SD service instance resolved into a usable connection target.
Returned by Mdns.resolveService and attached to browse events when
resolve: true is set. target and port come from the SRV record;
addresses are the A/AAAA records resolved for target; txt holds the
parsed TXT attributes, where a bare boolean key maps to true and a
key=value pair maps to the raw value bytes (decode with TextDecoder when
the value is textual). Callers typically connect to the first entry of
addresses on port.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const service: import('fino:net/mdns').MdnsService =
await mdns.resolveService('Printer._http._tcp.local');
const path = service.txt.get('path');
const url = `http://${service.addresses[0]}:${service.port}` +
(path instanceof Uint8Array ? new TextDecoder().decode(path) : '');
await mdns.close();
Properties
name: string
Service instance name.
target: string
SRV target hostname.
port: number
SRV target port.
txt: Map<string, Uint8Array | true>
DNS-SD TXT attributes keyed case-insensitively.
addresses: string[]
A/AAAA addresses resolved for target.
interfaceIndex: number | null
Interface index when known.
interface MdnsPublishService {
Description of a DNS-SD service to advertise with Mdns.publish.
name is the human-readable instance label (for example Printer) and is
combined with serviceType to form the full instance name. target and
port populate the SRV record; target must resolve to the addresses you
advertise. txt becomes the TXT record: string values encode as key=value
UTF-8, Uint8Array values encode as key= followed by the raw bytes, and
true encodes as a bare boolean key. Provide addresses for the A/AAAA
records the responder should return, or addressesByInterface to answer with
different addresses depending on the receiving interface index.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const registration = await mdns.publish({
name: 'Front Desk Printer',
serviceType: '_http._tcp.local',
target: 'printer.local',
port: 8080,
txt: { path: '/print', color: 'true' },
addresses: ['192.168.1.44'],
});
await registration.close();
await mdns.close();
Properties
name: string
Instance label without the service type, such as Printer.
serviceType: string
DNS-SD service type, such as _http._tcp.local.
target: string
SRV target hostname.
port: number
SRV target port.
txt?: Record<string, string | Uint8Array | true>
TXT attributes. String values encode as UTF-8; true encodes as a boolean key.
addresses?: string[]
Target A/AAAA addresses.
addressesByInterface?: Record<number, string[]>
Target A/AAAA addresses to use for specific interface indexes.
interface MdnsPublishOptions {
Socket, probing, and lifecycle options for Mdns.publish.
By default the responder binds the IPv4 mDNS port and joins the multicast
group so it answers real link-local queries. Setting probeAddress runs a
pre-publish conflict probe against that destination; if a conflicting record
is seen, conflictResolution decides between rejecting the publish
('reject', the default) and automatically appending a numeric suffix
('rename', up to maxRenameAttempts times). The announceAddress and
goodbyeAddress fields direct the unsolicited announcement and shutdown
goodbye packets to explicit destinations, which — together with an explicit
address and probeAddress — make publication fully deterministic in tests.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const registration = await mdns.publish(
{ name: 'Printer', serviceType: '_http._tcp.local', target: 'printer.local', port: 8080 },
{
probeAddress: { family: 'ipv4', ip: '224.0.0.251', port: 5353 },
conflictResolution: 'rename',
maxRenameAttempts: 3,
},
);
console.log('published as', registration.name);
await registration.close();
await mdns.close();
Properties
address?: Address
Bind address for the responder. Defaults to IPv4 mDNS port 5353.
multicast?: boolean
Join the mDNS multicast group for the bound address family. Defaults to true only for the default bind address.
interfaceAddress?: string
IPv4 interface address used for multicast membership and outbound packets.
interfaceIndex?: number
IPv6 interface index used for multicast membership and outbound packets.
probeAddress?: Address
Probe this destination for conflicting service records before publishing.
probeTimeoutMs?: number
Probe response timeout in milliseconds. Defaults to 250.
conflictResolution?: 'reject' | 'rename'
Conflict handling after probing. Defaults to rejecting the publish call.
maxRenameAttempts?: number
Maximum automatic rename attempts when conflictResolution is rename. Defaults to 5.
announceAddress?: Address
Send an unsolicited announcement packet to this destination after binding.
goodbyeAddress?: Address
Send goodbye records to this destination on close in addition to known queriers.
responseDelayMs?: number
Aggregate matching responses for this many milliseconds before sending. Defaults to immediate replies.
interface MdnsRegistration {
Handle to a live service advertisement returned by Mdns.publish.
The registration keeps answering PTR/SRV/TXT/A/AAAA questions until it is
closed. address reports the actual bound UDP address (useful when the
publish call bound port 0), and name reports the final instance name, which
may differ from the requested label if a probing conflict triggered an
automatic rename. It implements Symbol.asyncDispose, so it can be managed
with await using for automatic cleanup.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
{
await using registration = await mdns.publish({
name: 'Printer', serviceType: '_http._tcp.local', target: 'printer.local', port: 8080,
});
console.log('serving on', registration.address.port, 'as', registration.name);
// ... run while the service should stay discoverable ...
} // registration.close() runs here, sending goodbye records
await mdns.close();
Readonly Properties
readonly address: Address
Bound UDP address for the responder.
readonly name: string
Final DNS-SD service instance name. This may include an automatic rename suffix.
Methods
close(): Promise<void>
Stop answering, send goodbye records, and close the UDP socket.
Classes
class Mdns {
A client for local-link name resolution, service discovery, and publication.
A single instance covers the whole mDNS/DNS-SD surface: query and
resolveHost for one-shot name resolution, browse for DNS-SD service
enumeration (one-shot or continuous), resolveService to turn an instance
name into a connection target, and publish to advertise a service from a
built-in responder. Each query operation opens and closes its own transient
UDP socket, so instances hold no long-lived link sockets of their own; a
published service keeps its responder socket alive until the returned
registration is closed. Resolved records are cached per instance keyed by
name, record type, and destination, honoring the smallest record TTL.
Call close() when finished. Closed clients reject query, browse, and
publish; already-open registrations are unaffected. The client also
implements Symbol.asyncDispose for await using.
import { Mdns } from 'fino:net/mdns';
await using mdns = new Mdns();
const addresses = await mdns.resolveHost('printer.local');
for await (const event of mdns.browse('_http._tcp.local')) {
console.log('found', event.name);
}
Methods
async query(
name: string,
rrtype: MdnsRecordType,
options: MdnsQueryOptions = {
}
): Promise<DnsResourceRecord[]>
Send an mDNS query for a name and record type and return the records seen.
The query is transmitted over UDP and the method collects parsed resource
records from the answer, authority, and additional sections of every
matching response, deduplicated, until either the answer burst settles or
the timeout elapses. Unanswered one-shot queries are retransmitted with
randomized backoff between retryMinMs and retryMaxMs. When source
validation is enabled (the default for multicast destinations), responses
from addresses that are not on a local link are ignored. Fresh cached
records short-circuit the network round trip unless cache: false or
known answers are supplied.
Throws if the client is closed, if the signal is already aborted or
aborts during the wait (rejecting with the signal reason), or if no records
arrive before the timeout (an Error with code 'ETIMEOUT').
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const srv = await mdns.query('Printer._http._tcp.local', 'SRV', { timeoutMs: 1500 });
console.log(srv.map((record) => record.data));
await mdns.close();
async resolveHost(name: string, options: MdnsQueryOptions = {}): Promise<string[]>
Resolve a .local hostname to its A/AAAA addresses as strings.
A convenience wrapper over query that asks for A records (or AAAA
when the destination server is IPv6), then returns just the address
strings from the matching records. Shares the timeout, retry, cache, and
error semantics of query, so it throws with code 'ETIMEOUT' when the
name does not answer in time.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const addresses = await mdns.resolveHost('printer.local');
console.log(addresses); // e.g. ['192.168.1.44']
await mdns.close();
browse(serviceType: string, options: MdnsQueryOptions = {}): AsyncIterable<MdnsBrowseEvent>
Browse a DNS-SD service type, yielding an event per instance lifetime change.
Returns an async iterable of MdnsBrowseEvent. By default it performs one
PTR query and yields a single up event per unique instance, then
completes. With continuous: true the iterator stays open, repolls every
pollMs, ingests unsolicited packets on its bound socket, and additionally
emits update when an instance's TTL or (with resolve: true) resolved
metadata changes and down when a zero-TTL goodbye record arrives or a
TTL lapses. Pass resolve: true to attach a resolved service to up and
update events. A continuous iteration ends when the signal aborts or the
client is closed; iterating a closed client simply yields nothing.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const controller = new AbortController();
for await (const event of mdns.browse('_http._tcp.local', {
continuous: true,
resolve: true,
signal: controller.signal,
})) {
if (event.type === 'down') console.log('left:', event.name);
else console.log(event.type, event.name, event.service?.port);
}
async resolveService(instance: string, options: MdnsQueryOptions = {}): Promise<MdnsService>
Resolve a DNS-SD service instance name into a connection target.
Queries the instance's SRV record for its target host and port, then fills
in TXT metadata and A/AAAA addresses — preferring records already present
in the SRV response's additional section and falling back to follow-up
queries for TXT and for the target's addresses when they are absent. The
returned MdnsService carries the target, port, parsed TXT map, and
resolved addresses.
Throws if the instance has no SRV record (an Error mentioning the
instance name), and propagates the timeout error from the underlying SRV
query when the instance does not answer.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
const service = await mdns.resolveService('Printer._http._tcp.local');
console.log(`${service.target}:${service.port}`, service.addresses);
await mdns.close();
async publish(
service: MdnsPublishService,
options: MdnsPublishOptions = {
}
): Promise<MdnsRegistration>
Advertise a DNS-SD service from a built-in responder and return its handle.
Binds a UDP responder that answers PTR, SRV, TXT, A, and AAAA questions for
the supplied service, applying duplicate-question suppression and optional
response aggregation (responseDelayMs). When multicast publication is
active it joins the mDNS group across known interfaces and sends an
unsolicited announcement after binding. If probeAddress is set it first
probes for a conflicting instance and either rejects or renames according to
conflictResolution. The returned MdnsRegistration reports the bound
address and the final (possibly renamed) instance name and keeps serving
until closed, at which point goodbye records are sent.
Throws if the client is closed, and — when probing finds an unresolved
conflict under conflictResolution: 'reject' or after exhausting
maxRenameAttempts — an Error with code 'EADDRINUSE'.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
await using registration = await mdns.publish({
name: 'Printer',
serviceType: '_http._tcp.local',
target: 'printer.local',
port: 8080,
txt: { path: '/print' },
addresses: ['192.168.1.44'],
});
console.log('advertising', registration.name, 'on', registration.address.port);
async close(): Promise<void>
Mark the client closed so further operations are rejected.
The Mdns instance itself owns no persistent sockets — each query opens
and closes its own — so closing is cheap and mainly a guard: after it,
query, browse, and publish throw. Already-open registrations from
publish are independent and must be closed through their own handles.
Idempotent and safe to call more than once.
import { Mdns } from 'fino:net/mdns';
const mdns = new Mdns();
try {
await mdns.resolveHost('printer.local');
} finally {
await mdns.close();
}