Observed since 20 Oct, 20:04 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Discover mDNS/Bonjour servers on your LAN from a React overlay or CLI, and remember the one you pick.
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.
npm install 4b-react-mdns
npx 4bmdns config
config wires both halves — it adds mdnsPlugin() to your Vite config and wraps whatever your
entry point renders in <MdnsProvider>, above every route — then asks whether the provider
should also hold a live connection to the server you pick:
┌─ 4B App Configuration 1.5.0 • config ─────────────────────────────────┐
│ │
│ Configuring ~/code/my-app │
│ │
│ ✔ vite config vite.config.js added mdnsPlugin({ httpOnly: true })│
│ ✔ entry point src/main.jsx wrapped the rendered tree in │
│ <MdnsProvider httpOnly={true} │
│ launcher={false}> │
│ │
│ Live link to the picked server? │
│ ❯ None just the picker — add one later with --ws or --socket │
│ WebSocket ws://<picked server>/ — reconnecting, no dependency │
│ Socket.IO socket.io-client — you install it, we wire io() │
│ Both a WebSocket and a Socket.IO connection │
│ │
│ ✔ Done. Restart the dev server and the picker is live. │
└───────────────────────────────────────────────────────────────────────┘
--- 4brains technologies (2026)
Nothing socket-related is written unless you pick it here or pass --ws / --socket. The
question only appears in a real terminal; a pipe or CI run gets the result and no prompt. Changed
your mind later? npx 4bmdns link switches it — see Switching later.
createRoot(document.getElementById('root')).render(
<MdnsProvider httpOnly={true} launcher={false}>
<StrictMode>
<App />
</StrictMode>
</MdnsProvider>,
)
The two props are always written out with their values, so whoever reads the file later sees
what was decided and can flip it. The defaults filter to web servers and skip the launcher pill:
restart the dev server, press Space or double-tap the screen (or call useMdns().open()
from your own button) and the picker opens. Pick a server and it's saved.
| Flag | |
|---|---|
--http-only / --all | httpOnly={true} (default) / httpOnly={false} |
--no-launcher / --launcher | launcher={false} (default) / launcher={true} — show the pill |
--ws / --no-ws | ws={true} onWsMessage={pushRelay} plus a src/mdnsRelay.js helper / ws={false} |
--socket / --no-socket | socket={true} socketClient={io} / socket={false} — Socket.IO |
--dry-run | Show the plan without writing |
npx 4bmdns config --launcher --ws
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 — a props-per-line tag stays props-per-line. 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} launcher={false}>
<App />
</MdnsProvider>
)
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 |
Next to the pick the panel keeps one more flag, the Test / Live mode:
| Where | What |
|---|---|
localStorage["4b_App_Mode"] | the string "true" (Live) or "false" (Test). Test is the default: the key is written as "false" the first time it is read |
A value left under an earlier name (4b_mDNS_Mode or 4b_mDNS_live) is moved to 4b_App_Mode
the first time it is read, and the old key is removed.
It is browser-only and never written to disk. The switch sits in the panel's status row beside the
HTTP filter. Read it from a component with useMdns().live (a boolean) or useMdns().mode
("test" / "live"), or from a plain api module without the provider:
import { isLiveMode } from '4b-react-mdns/react'
const base = `http://${getSelectedAddress()}${isLiveMode() ? '/api' : '/api/test'}`
// or, with no import at all:
const live = localStorage.getItem('4b_App_Mode') === 'true'
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. Where the two disagree the file wins,
with one exception: a scanner holding no file at all does not clear a pick the browser already
remembers, since the stores are independent and an empty file is not a decision to forget
anything. Forget in the panel (or clear()) empties the browser's copy.
mDNS only reaches the local subnet, and some networks block multicast outright. The
Enter the server address field at the top of the panel covers that case: type
192.168.0.10:3000 (or a hostname, a bracketed IPv6 literal, or a full URL — the scheme and path
are dropped) and press Use this address. It is stored exactly like a pick from the list, so
localStorage["4b_mDNS_ip"] and the
JSON file both update, and useMdns().address changes the same way. The record carries
manual: true so you can tell it apart.
Saving is browser-first: the pick is written to localStorage before the API is asked to persist it, so it survives the scanner being unreachable. In that case the panel keeps the browser copy and shows a banner saying the disk copy is missing.
Hide the box with <ScannerPanel showManualEntry={false}>. To build your own form, use the pieces:
import { ManualEntry, parseAddress, manualService, useMdns } from '4b-react-mdns/react'
const { select } = useMdns()
const parsed = parseAddress('http://nas.local:8080/admin') // { host: 'nas.local', port: 8080 }
if (!parsed.error) select(manualService(parsed.host, parsed.port))
<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 Web servers 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 |
hotkey | " " (Space) | Key that toggles the panel when focus isn't in a field or on a button. Any KeyboardEvent.key value, or false to disable |
doubleTap | true | Open the panel on a double tap (or double click) anywhere outside a field, button or link. false to disable, or { delay, slop } to tune the ms allowed between taps (350) and the px they may drift (30) |
onSelect | — | Called with the record whenever the pick changes |
modeToggle | true | Show the Test / Live switch in the panel. false hides it; the saved flag still applies |
onModeChange | — | Called with (mode, live) — ("live", true) or ("test", false) — each time the switch is flipped |
disconnectNotice | true | Pop up a notice in the launcher's corner when a socket drops, with Retry and Check server buttons. false to turn it off |
ws | false | Keep a reconnecting WebSocket open to the picked server. true, or an options object — see Sockets |
wsPath | / | Path the WebSocket dials on the pick, e.g. /live |
wsUrl | — | Dial this instead: a string, or (selection) => url |
wsProtocols | — | Sec-WebSocket-Protocol, a string or array |
wsReconnect | true | false to reconnect never, or { maxAttempts, baseDelay, maxDelay, factor, jitter } |
wsDecode | auto | auto parses JSON when it is JSON, json always tries, raw leaves the string alone |
wsLastMessage | true | Keep ws.lastMessage up to date. false for a chatty server: messages then reach ws.on(), onWsMessage and the relay only, and nothing re-renders per message |
onWsMessage / onWsStatus / onWsOpen / onWsClose / onWsError | — | WebSocket callbacks. onWsMessage(data, message, event); onWsStatus(status, snapshot) |
socket | false | Keep a Socket.IO connection open to the picked server. true, or an options object |
socketClient | window.io | The io export of socket.io-client. The library is not bundled |
socketPath | /socket.io | Socket.IO path on the server |
socketUrl | — | Dial this instead: a string, or (selection) => url |
socketOptions | — | Passed to io(url, options) — transports, auth, reconnectionAttempts … |
socketLastEvent | true | Keep socket.lastEvent up to date. false for a busy server; events then reach socket.on() and onSocketEvent only |
onSocketStatus / onSocketEvent / onSocketConnect / onSocketDisconnect | — | Socket.IO callbacks. onSocketEvent(event, ...args) fires for every server event |
useMdns()const {
selection, ip, address, url, // the pick
select, clear, // change it
services, connected, rescan, // the scan
httpOnly, setHttpOnly, // the filter
mode, live, setMode, setLive, // Test / Live — "test" | "live", and the boolean behind it
open, close, toggle, isOpen, // the panel
ws, socket, // sockets to the pick — see below
} = useMdns()
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.
Most apps that pick a LAN server go on to hold a live connection to it, and a browser
WebSocket closes once and stays closed — a server restart or a phone changing Wi-Fi leaves the
app talking to nothing. The provider can own that connection for you. Both kinds are off by
default; turn one on and it dials the pick, follows it when the pick changes, reconnects when it
drops, and shows its status in the panel and as an extra dot on the launcher.
<MdnsProvider ws={true}> {/* ws://<address>/ */}
<MdnsProvider ws={{ path: '/live', reconnect: { maxAttempts: 10, maxDelay: 5000 } }}>
function Readings() {
const { ws } = useMdns()
// 'off' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'error'
if (ws.status !== 'open') return <p>{ws.status}{ws.attempt ? ` (attempt ${ws.attempt})` : ''}</p>
return (
<>
<pre>{JSON.stringify(ws.lastMessage?.data)}</pre>
<button onClick={() => ws.send({ cmd: 'refresh' })}>Refresh</button>
</>
)
}
ws carries status, connected, attempt, error, url, lastMessage ({ data, raw, at },
with data already parsed when the server sent JSON), nextRetryAt, queued, and the actions
send, on, connect, disconnect, reconnect, getSocket. send() takes an object and JSON-encodes
it, or a string / ArrayBuffer / Blob as-is; while the link is down the message waits in a small
outbox and goes out on open. disconnect() closes on purpose — no retries until connect().
Reconnection is exponential back-off from baseDelay to maxDelay with jitter, restarted the
moment the browser fires online. The scheme follows the pick: an _https._tcp service gets
wss://. (A page served over https:// cannot open a plain ws:// socket — browsers block mixed
content — so serve the app over http:// on the LAN or give the server TLS.)
Socket.IO is the same shape. The client library is yours to install, since bundling it here would force it on apps that use plain WebSockets:
npm install socket.io-client
npx 4bmdns config --socket # writes socket={true} socketClient={io} and the import
import { io } from 'socket.io-client'
<MdnsProvider socket={true} socketClient={io} socketOptions={{ auth: { token } }}>
function Room() {
const { socket } = useMdns()
const [state, setState] = useState()
// on() returns the unsubscribe, so it drops straight into useEffect.
useEffect(() => socket.on('state', setState), [socket.on, socket.status])
return <button onClick={() => socket.emit('join', 'lobby')}>{socket.status}</button>
}
socket carries status, connected, attempt, error, id, url, lastEvent
({ event, args, at }) and the actions emit, on, connect, disconnect, reconnect,
getSocket. Socket.IO runs its own reconnection; the provider mirrors its connect,
disconnect, connect_error and reconnect_attempt events into the same status words the
WebSocket uses, so one status component serves both. Without a client it reports status: 'error'
and says what to pass.
Both connections live in the provider, so any component reads them from useMdns() — no prop
drilling, no second connection. Here is a component that shows live readings over the WebSocket
and sends commands back:
import { useEffect, useState } from 'react'
import { useMdns } from '4b-react-mdns/react'
function Readings() {
const { ws } = useMdns()
const [reading, setReading] = useState(null)
// Every message, as it arrives. `data` is already parsed when the server sent JSON.
useEffect(() => {
if (ws.lastMessage) setReading(ws.lastMessage.data)
}, [ws.lastMessage])
const refresh = () => ws.send({ cmd: 'refresh' }) // objects are JSON-encoded
const raw = () => ws.send('PING') // strings go through as-is
return (
<section>
<p>
{ws.status === 'open' && '● live'}
{ws.status === 'reconnecting' && `● reconnecting (attempt ${ws.attempt})`}
{ws.status === 'error' && `● ${ws.error}`}
{ws.status === 'off' && '○ pick a server first'}
</p>
<pre>{JSON.stringify(reading, null, 2)}</pre>
<button onClick={refresh} disabled={!ws.connected}>Refresh</button>
<button onClick={raw}>Ping</button>
</section>
)
}
Reading ws.lastMessage re-renders on every message — and because it lives in the provider's
context, so does every component that calls useMdns(), whether it reads it or not. That is fine
for a reading a few times a second and wrong for a server that streams sensor frames or cursor
positions. For those, turn the tracking off and subscribe instead — no re-render anywhere, and the
subscription survives reconnects and a change of pick:
<MdnsProvider ws={true} wsLastMessage={false}>
function Cursor() {
const { ws } = useMdns()
const pos = useRef({ x: 0, y: 0 })
// on() returns the unsubscribe, so it drops straight into useEffect.
useEffect(() => ws.on((data) => { pos.current = data }), [ws.on])
...
}
config --ws scaffolds the same idea as a file, src/mdnsRelay.js, and wires it with
onWsMessage={pushRelay} on the provider. Any module — React or not — can then listen:
import { onRelay } from './mdnsRelay'
useEffect(() => onRelay((data) => handle(data)), [])
The file is generated once and never rewritten, so it is yours to edit. Delete the prop and the
import if you would rather use ws.on() or onWsMessage directly.
The same with Socket.IO — named events in, named events out:
function Room({ name }) {
const { socket } = useMdns()
const [members, setMembers] = useState([])
const [chat, setChat] = useState([])
// socket.on() returns the unsubscribe, so it drops straight into useEffect.
// Re-run on status so a fresh connection is subscribed again.
useEffect(() => socket.on('members', setMembers), [socket.on, socket.status])
useEffect(
() => socket.on('chat', (msg) => setChat((c) => [...c, msg])),
[socket.on, socket.status]
)
// Join when connected — and again after every reconnect, since the server forgot us.
useEffect(() => {
if (socket.connected) socket.emit('join', name)
}, [socket.connected, name])
const say = (text) => socket.emit('chat', { name, text })
return (
<section>
<p>{socket.connected ? `● in ${name} as ${socket.id}` : `○ ${socket.status}`}</p>
<ul>{members.map((m) => <li key={m}>{m}</li>)}</ul>
<ul>{chat.map((m, i) => <li key={i}><b>{m.name}</b> {m.text}</li>)}</ul>
<button onClick={() => say('hi')}>Say hi</button>
</section>
)
}
socket.emit() takes the same arguments as Socket.IO's own, acknowledgements included:
socket.emit('save', payload, (ack) => console.log('server said', ack))
For anything the wrappers don't cover, ws.getSocket() returns the browser WebSocket and
socket.getSocket() the Socket.IO Socket — the current one, so read it when you need it rather
than holding on to it across a reconnect.
Wired Socket.IO and now want a WebSocket, or the other way round? Run link in the app:
npx 4bmdns link ws # Socket.IO → WebSocket
npx 4bmdns link socket # WebSocket → Socket.IO
npx 4bmdns link both # keep both
npx 4bmdns link none # picker only
npx 4bmdns link # a menu, with the current setup marked
┌─ 4B App Configuration 1.5.0 • link ───────────────────────────────────┐
│ │
│ Live link in ~/code/my-app │
│ │
│ Now: Socket.IO src/main.jsx │
│ │
│ ✔ entry point src/main.jsx set ws={true} socket={false} on │
│ <MdnsProvider> │
│ │
│ ✔ Switched Socket.IO ❯ WebSocket. Restart the dev server. │
└───────────────────────────────────────────────────────────────────────┘
--- 4brains technologies (2026)
It edits only the ws and socket props on the tag and leaves everything else — baseUrl,
onSelect, your callbacks — as it was. Turning Socket.IO off also removes the socketClient={io}
prop and the import { io } from 'socket.io-client' line it came with (only if nothing else in
the file uses io), so you can npm uninstall socket.io-client without a dangling import.
Turning the WebSocket off likewise removes onWsMessage={pushRelay} and its import; the
mdnsRelay file stays, since you may have edited it. An onWsMessage you wrote yourself is
never touched. The flags config --ws / --socket still work but are additive; link replaces.
A socket that was up and is now retrying pops a small notice in the launcher's corner:
WebSocket disconnected Trying to reconnect — attempt 3. Check the server is running.
ws://192.168.0.10:3000/[Retry now] [Check server]
Retry now skips the back-off and dials immediately. Check server opens the picker, where
the connections row shows the same status and you can pick another server or rescan. The notice
dismisses itself when the link is back, after a short green "reconnected" flash, or when you close
it for this outage. A link that never connected says "can't reach the server" instead. It stays
out of the way while the picker is open, and disconnectNotice={false} turns it off entirely if
you show the status your own way.
The pieces are exported for use on their own, inside or outside the provider:
import { useWebSocket, useSocketIo, ReconnectingSocket, linkUrl } from '4b-react-mdns/react'
const link = useWebSocket('ws://192.168.0.10:3000/live', { onMessage: setReading })
const sio = useSocketIo(linkUrl(selection, { scheme: 'http', path: '' }), { client: io })
// No React: the class the hook is built on.
const rs = new ReconnectingSocket('ws://192.168.0.10:3000/')
rs.on('message', ({ data }) => console.log(data))
rs.connect()
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 Web servers 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 */}
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.
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 link ws # switch the live link: ws, socket, both or none
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 |
--launcher / --no-launcher | off | For config: write launcher={true} / launcher={false} |
--ws / --no-ws | — | For config: write ws={true} / ws={false} |
--socket / --no-socket | — | For config: write socket={true} socketClient={io} / socket={false} |
--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.
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.
| 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 |
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 address 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.
Node 20+. React 18 or 19 as a peer dependency, needed only for the /react entry.
socket.io-client 4+ is an optional peer, needed only when socket is on.
git clone https://git.4brains.in/shailesh.gautam/react-mdns-package.git
cd react-mdns-package
npm install
npm 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 # oxlint
npm 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-package
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 |
MIT
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 1.8.0Registry latest | 6 Oct 2026 | 9 | |
| 1.7.0 | 29 Sept 2026 | 9 | |
| 1.6.1 | 29 Sept 2026 | 9 | |
| 1.5.0 | 29 Sept 2026 | 9 | |
| 1.4.8 | 28 Sept 2026 | 8 | |
| 1.4.4 | 28 Sept 2026 | 8 | |
| 1.1.0 | 28 Sept 2026 | 8 | |
| 0.8.1 | 19 Sept 2026 | 8 | |
| 0.8.0 | 12 Sept 2026 | 8 | |
| 0.7.2 | 10 Sept 2026 | 8 | |
| 0.7.1 | 10 Sept 2026 | 8 | |
| 0.7.0 | 10 Sept 2026 | 8 | |
| 0.6.0 | 10 Sept 2026 | 8 | |
| 0.5.0 | 10 Sept 2026 | 8 | |
| 0.4.0 | 10 Sept 2026 | 8 | |
| 0.3.0 | 10 Sept 2026 | 8 | |
| 0.2.0 | 10 Sept 2026 | 8 | |
| 0.1.0 | 9 Sept 2026 | 8 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 20:04 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:e8bce870cc5855443f90facc7eddc924b7e7bad321fadbdd09a6dd8a148f7135