4b-react-mdns
v0.8.1
Published
Discover mDNS/Bonjour servers on your LAN from a React overlay or CLI, and remember the one you pick.
Maintainers
Readme
4b-react-mdns
Let people pick a server off the local network from inside your React app, and remember which one they chose.
Browsers can't speak mDNS — it's UDP multicast on :5353 — so discovery runs in Node and the React
side consumes a small HTTP + Server-Sent-Events API. The package ships both halves: a Vite plugin
(or standalone server) for the Node side, and a provider and hooks for the browser side.
Quick start
npm install 4b-react-mdns
npx 4bmdns configconfig wires both halves — it adds mdnsPlugin() to your Vite config and wraps whatever your
entry point renders in <MdnsProvider>, above every route:
+ vite config (vite.config.js) — added mdnsPlugin({ httpOnly: true })
+ entry point (src/main.jsx) — wrapped the rendered treeRestart the dev server and a Scan network pill appears bottom-right. Pick a server and it's saved.
| Flag | |
| --- | --- |
| --http-only | Filter to _http._tcp / _https._tcp — writes httpOnly={true} |
| --no-launcher / --launcher | Write launcher={false} / launcher={true} |
| --dry-run | Show the plan without writing |
npx 4bmdns config --http-only --no-launchercreateRoot(document.getElementById('root')).render(
<MdnsProvider launcher={false} httpOnly={true}>
<StrictMode>
<App />
</StrictMode>
</MdnsProvider>,
)Use --no-launcher when you'd rather open the picker from your own button — see
Your own button.
Re-running is safe. A bare config leaves an existing setup alone; pass a flag and it updates
that prop in place, keeping every other prop on the tag (baseUrl, onSelect, position …)
and the formatting. If it doesn't recognise a file it leaves it alone and prints the snippet to
paste rather than guessing.
// vite.config.js
import { mdnsPlugin } from '4b-react-mdns/vite'
export default defineConfig({
plugins: [react(), mdnsPlugin({ httpOnly: true })],
server: { host: true }, // optional: reachable from a phone on the same Wi-Fi
})// src/main.jsx
import { MdnsProvider } from '4b-react-mdns/react'
createRoot(document.getElementById('root')).render(
<MdnsProvider httpOnly={true}>
<App />
</MdnsProvider>
)Reading the pick
Anywhere inside the provider:
import { useMdns } from '4b-react-mdns/react'
function Player() {
const { address, open } = useMdns()
if (!address) return <button onClick={open}>Choose a server</button>
return <video src={`http://${address}/stream`} />
}Outside React — in a plain api client, say:
import { getSelectedAddress } from '4b-react-mdns/react'
const base = `http://${getSelectedAddress()}` // "192.168.0.224:3000"The pick is stored in three places, and you can read any of them:
| Where | What |
| --- | --- |
| localStorage["4b_mDNS_ip"] | the plain ip:port string |
| localStorage["4b_mDNS_selection"] | the full record as JSON |
| ~/.scan-net/selection.json | the same record, on the machine running the scanner |
The file is the durable copy — it survives a cleared browser, is shared by every app pointing at that scanner, and is readable from shell scripts and Node.
<MdnsProvider>
Wraps your app and layers the picker over it. Children render untouched — the provider adds no
wrapper element. The panel is portalled to <body> at z-index: 2147483000, so it sits above
routed content no matter where the provider is mounted or what stacking contexts your app creates.
Styles are injected at runtime and every selector is scoped under .snet-root, so there's no
stylesheet to import and nothing bleeds either way.
Everything inside the panel is sized in em off a single custom property, so the whole thing
scales as one piece. --snet-fs is flat at 15px up to roughly 1920px of viewport width and
grows beyond it — a 4K screen at 100% scaling gets a panel sized for it instead of a postage
stamp. Pin it to opt out:
.snet-root { --snet-fs: 15px } /* or whatever size you want the panel to sit at */| Prop | Default | |
| --- | --- | --- |
| baseUrl | /api | Where the scanner API is mounted |
| httpOnly | server default | true = only _http/_https._tcp, false = every service. Omit to follow the CLI's --http-only flag |
| filterToggle | false when httpOnly is set | Show the HTTP only switch in the panel |
| launcher | true | Show the built-in launcher pill. false removes it entirely |
| launcherLabel | Scan network | Its text |
| position | bottom-right | Its corner — also bottom-left, top-right, top-left |
| open | — | Pass a boolean to control visibility yourself |
| onOpenChange | — | Called with true/false when the user opens or dismisses the panel |
| defaultOpen | false | Starting state when open isn't passed |
| closeOnEscape / closeOnBackdrop | true | Dismiss behaviour |
| onSelect | — | Called with the record whenever the pick changes |
useMdns()
const {
selection, ip, address, url, // the pick
select, clear, // change it
services, connected, rescan, // the scan
httpOnly, setHttpOnly, // the filter
open, close, toggle, isOpen, // the panel
} = useMdns()Your own button
Drop the built-in pill and open the panel from anywhere:
function ServerButton() {
const { open, address } = useMdns()
return <button onClick={open}>{address ?? 'Pick a server'}</button>
}
<MdnsProvider launcher={false}>
<Header><ServerButton /></Header>
<Routes />
</MdnsProvider>To hold the state yourself — driving it from a router, a menu, a keyboard shortcut — pass open
and it becomes a controlled component:
const [scanning, setScanning] = useState(false)
<MdnsProvider launcher={false} open={scanning} onOpenChange={setScanning} httpOnly>
<button onClick={() => setScanning(true)}>Scan network</button>
<Routes />
</MdnsProvider>While open is a boolean the provider never changes it on its own: Escape, the ✕ and a backdrop
click all report through onOpenChange and leave the decision to you. Omit open and it manages
itself again.
<MdnsList>
Renders the scanned services on your own page instead of behind the launcher:
<MdnsProvider httpOnly>
<h2>Pick a streaming server</h2>
<MdnsList />
</MdnsProvider>| Prop | Default | |
| --- | --- | --- |
| httpOnly | provider's value | Override the filter for this list only |
| query | '' | Substring filter over name, type, host, addresses |
| limit | — | Show at most this many |
| columns | true | false renders a single column |
| emptyLabel | — | Text when nothing matches |
| onSelect | — | Called after a card is picked |
Selecting from the list saves exactly as the overlay does. For full control over the markup, read
useMdns().services and render the rows yourself.
Filtering to web servers
Most networks are noisy — printers, AirPlay targets, every laptop. httpOnly narrows the list to
_http._tcp and _https._tcp:
mDNS: advertising "streaming-server" at Mac.local:3000 (_http._tcp.local)
mDNS: advertising "4bnode-app (4brains)" at 4brains.local:4021 (_http._tcp.local)It's settable at every level, each overriding the one above:
npx 4bmdns --http-only # the server default<MdnsProvider httpOnly={true}> {/* the app's default */}
<MdnsList httpOnly={false} /> {/* just this list */}The HTTP only toggle in the panel flips it live. The UI streams every service and filters in the browser, which is what lets the toggle switch both ways instantly.
Passing httpOnly hides that toggle — the app has decided, so offering a switch that contradicts
it only invites confusion. Pass filterToggle to bring it back:
<MdnsProvider httpOnly> {/* filtered, no toggle */}
<MdnsProvider httpOnly filterToggle> {/* filtered, toggle shown */}
<MdnsProvider> {/* server default, toggle shown */}Serving the API
The provider needs the scanner API on baseUrl. Three ways to get it there.
The Vite plugin covers npm run dev and npm run preview:
import { mdnsPlugin } from '4b-react-mdns/vite'
export default defineConfig({ plugins: [react(), mdnsPlugin({ httpOnly: true })] })A separate process — for production, or when your app already serves its own /api/* and the
routes would collide:
npx 4bmdns -p 4173<MdnsProvider baseUrl="http://localhost:4173/api">Embedded in your own server:
import { createServer } from '4b-react-mdns'
const { urls, storeFile, close } = await createServer({ port: 4173, httpOnly: true })If the API isn't reachable the panel says so — a 404 reports that the plugin is missing rather than pretending the connection dropped.
CLI
4bmdns and scan-net are the same command.
npx 4bmdns # start the scanner UI on :4173
npx 4bmdns config # wire the scanner into the app in this directory
npx 4bmdns which # print the selected server
npx 4bmdns forget # clear it$ 4bmdns which
streaming-server -> 192.168.0.224:3000
http://192.168.0.224:3000| Flag | Default | |
| --- | --- | --- |
| -p, --port <number> | 4173 | Port to listen on |
| -H, --host <host> | 0.0.0.0 | Interface to bind |
| --http-only | off | List only _http._tcp / _https._tcp |
| --all | on | List every advertised service |
| --store <dir> | ~/.scan-net | Where the selection is saved |
| --dry-run | — | For config: show the plan without writing |
| --json | — | Machine-readable output for which |
| --no-open | — | Don't open a browser |
SCAN_NET_HOME overrides the store directory too, so you can scope a selection per project.
Node API
import { SelectionStore, createServer, MdnsScanner } from '4b-react-mdns'
const { ip, port, url } = new SelectionStore().read() ?? {}selection.json looks like this:
{
"id": "Living Room Pi._http._tcp.local",
"name": "Living Room Pi",
"kind": "Web server",
"type": "http",
"protocol": "tcp",
"host": "livingroom.local",
"ip": "192.168.1.42",
"port": 8080,
"address": "192.168.1.42:8080",
"url": "http://livingroom.local:8080",
"txt": null,
"savedAt": "2026-09-09T10:31:07.482Z"
}ip prefers IPv4, falls back to IPv6, then to the advertised .local hostname.
HTTP API
| Endpoint | |
| --- | --- |
| GET /api/status | { scanning, count, startedAt } |
| GET /api/config | { httpOnly } — the server's default filter |
| GET /api/services | services discovered so far; ?httpOnly= overrides the default |
| GET /api/stream | SSE: snapshot, then up / update / down; ?httpOnly= too |
| POST /api/scan | start scanning, or re-query to refresh |
| POST /api/stop | stop scanning and drop results |
| GET /api/selection | { selection, file } |
| POST /api/selection | save a service as the selection |
| DELETE /api/selection | clear it |
Using it from another device
With server: { host: true } (or the standalone CLI, which binds 0.0.0.0), the UI works from a
phone or laptop on the same Wi-Fi at http://<your-ip>:5173. Selecting there writes to the JSON
file on the machine running the scanner, which is usually what you want.
One caveat: a plain-HTTP LAN origin isn't a secure context, so browsers hide the async clipboard API entirely. Copy IP falls back to a legacy copy, and if that's blocked too it highlights the address so you can copy it manually. Clicking the address selects it as well.
There's no authentication on the API, so run it on networks you trust — or bind the standalone
server to loopback with -H 127.0.0.1.
Requirements
Node 20+. React 18 or 19 as a peer dependency, needed only for the /react entry.
Develop
git clone https://git.4brains.in/shailesh.gautam/react-mdns-package.git
cd react-mdns-package
npm installnpm run dev # Vite + the mDNS API on :5173
npm run build # emit dist/ (standalone UI) and dist/react/ (the library)
npm start # serve the built UI the way the published package does
npm run lint # oxlintnpm run build runs twice: once for the standalone page in dist/, once for the embeddable
library in dist/react/ with React left external. prepack runs it automatically, so npm publish
always ships a current build.
To try unreleased changes in a host app, link the working copy and rebuild after each edit — Vite won't hot-reload a linked dependency's server code:
cd ../your-app && npm install ../react-mdns-packageLayout
| | |
| --- | --- |
| server/ | scanner, HTTP API, Vite plugin, standalone server, config codemod |
| src/lib/ | the embeddable React entry — provider, hooks, panel, styles |
| src/ | the standalone page served by the CLI |
| bin/cli.js | 4bmdns / scan-net |
License
MIT
