mdns

js/net/mdns.ts

fino:net/mdns — Multicast DNS and DNS-SD discovery helpers.

Learn more:

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();
}