Discovery (mDNS / Bonjour)
dotnet add package Shiny.Net.HttpServer.DiscoveryBinding a port solves being reachable. It does not solve being found.
The address is assigned by whatever network the device joined, it changes when the device moves, and the ways out of that are: a QR code, someone typing an IP address, or this. mDNS is what every printer, speaker and NAS on the link already uses. It needs no server, no account and no internet.
Advertising
Section titled “Advertising”builder.Services.AddShinyHttpServer( http => { http.Options.Address = IPAddress.Any;
http.AddHttpServerAdvertisement(o => { o.ServiceType = "_myapp._tcp"; o.TxtRecords["role"] = "controller"; }); }, autoStart: false);The advertisement’s hosted service is registered after the server’s own, which is the order they start in, so the first announcement carries a port that is already bound. (It recovers either way — the advertiser watches the server’s state — but there is no reason to make it.)
The advertisement follows the server:
- published when it starts listening, on the port it actually bound (including an OS-assigned one),
- withdrawn with a goodbye packet when it stops, so peers drop it immediately rather than waiting out the TTL,
- re-published on the new port if the server is restarted onto one,
- re-announced when the device changes network, which is when a stale advertisement is worst,
- held through a stop that is only one half of a restart or a rebind — see below.
It follows the reason, not just the state
Section titled “It follows the reason, not just the state”A restart is a Stopped and a Running milliseconds apart, and a rebind after the phone moved
networks is the same. Withdrawing on that Stopped is wrong twice over: peers get a goodbye and then
a fresh announcement for a service that never actually went away, and anything holding a resolved
address drops it and has to find the device again over a gap that was never real.
The advertiser subscribes to server.StateTransitioned rather than StateChanged, so it can tell a
stop from a pause. A Stopped carrying Restarting or NetworkChanged leaves the record standing;
the Running that follows either finds the same port and does nothing at all, or finds a new one and
moves the record onto it. If the start half never lands, the core reports Stopped a second time with
BindFailed, and that one is a real stop and does withdraw. ListenerFaulted withdraws too —
recovering from it is optional and it is not instant, and a listener that died underneath the server
is exactly the outage the peers watching it should be told about.
When the responder will not take the registration
Section titled “When the responder will not take the registration”The moment the advertiser publishes is the moment a responder is least likely to answer: the server has just bound after a network change, and the platform’s mDNS stack is coming back at its own pace. One refused registration used to be the end of it, and the failure it left behind is the hard kind — a server running perfectly, answering everything, findable by nobody, and looking completely healthy from inside the app.
Publishing is retried on a bounded backoff:
| Property | Default | |
|---|---|---|
PublishAttempts |
3 |
1 restores the old single-attempt behaviour |
PublishRetryDelay |
1 second | Doubles per attempt |
MaxPublishRetryDelay |
15 seconds | Ceiling for the doubling |
When the attempts are spent it is logged at Error with the exception — not Warning, because a
crash reporter’s Microsoft.Extensions.Logging bridge files an event for the first and only a
breadcrumb for the second. A server that says it is running but reports no listen URL is logged too,
rather than silently publishing nothing; that is expected only for a server served entirely through a
tunnel, which has no local port to advertise.
State changes are also applied in the order the server actually moved in. The mDNS work has to come off the server’s lifecycle thread — it talks to a platform responder and it is slow — and once it is off, two transitions milliseconds apart are free to land in either order. A withdrawal applied on top of the publication that followed it would leave the server running and unfindable, with nothing coming to correct it.
Without a container:
await server.StartAsync();await using var advertisement = await server.AdvertiseAsync(mdns, o => o.ServiceType = "_myapp._tcp");| Property | Default | Notes |
|---|---|---|
ServiceType |
_http._tcp |
See below |
InstanceName |
the machine name | What the user already calls this device |
Path |
/ |
Published as the conventional path TXT record |
TxtRecords |
path, scheme |
Whatever a peer needs to decide it cares |
PublishAttempts |
3 |
See above |
PublishRetryDelay |
1 second | Doubles per attempt |
MaxPublishRetryDelay |
15 seconds | Ceiling for the doubling |
Read the final name back from IHttpServerAdvertiser.Publication. When the link already has a
service with that name the responder renames it — typically by appending (2) — and the publication
carries what it settled on.
Finding
Section titled “Finding”builder.Services.AddHttpServerLocator();
// one shot — "connect to my other device"var found = await locator.FindFirstAsync("_myapp._tcp", TimeSpan.FromSeconds(5));using var client = new HttpClient { BaseAddress = found!.BaseAddress };
// everything that answers inside the windowvar all = await locator.FindAllAsync("_myapp._tcp", TimeSpan.FromSeconds(3));
// or bind a list to it: never completes on its ownawait foreach (var change in locator.WatchAsync("_myapp._tcp", ct)){ if (change.Found) devices.Add(change.Server); else devices.RemoveAll(x => x.InstanceName == change.Server.InstanceName);}A DiscoveredHttpServer is already a URL a client can call: the base address is built from the
service’s scheme and path TXT records and its first IPv4 address, since that is still what a
person types and what a QR code carries. BaseAddressFor(address) builds the same URL through any
of its other addresses.
Filtering on a TXT record is how an app picks its own instance out of a crowded link:
var controller = await locator.FindFirstAsync( "_myapp._tcp", TimeSpan.FromSeconds(5), x => x.TxtRecords.TryGetValue("role", out var role) && role == "controller");An instance that has not resolved to an address and port yet is skipped rather than handed over with
no URL. Finding nothing is an answer — null, or an empty list — not a failure.
Platform requirements
Section titled “Platform requirements”Publishing and browsing go through NSNetService on Apple platforms and NsdManager on Android, so
no multicast entitlement is required. What is required:
| Platform | Needs |
|---|---|
| iOS / Mac Catalyst / macOS | NSLocalNetworkUsageDescription and every browsed service type in NSBonjourServices |
| Android | INTERNET and ACCESS_NETWORK_STATE |
| Windows / Linux / server .NET | A managed responder binds UDP 5353 — the firewall has to allow it |
Browsing for a type that is not declared in NSBonjourServices returns nothing, with no error. See
Mobile for the rest of the manifest entries and a runtime check for them.


