UNPKG

4bnode

Version:

4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.

238 lines (217 loc) 8.64 kB
// Bonjour / mDNS service discovery // Zero-config LAN networking: this app advertises itself so other devices can // find it by name (host.local) without hard-coded IPs, and can browse for other // services on the same network. // // This module is written to be defensive and robust. The lessons baked in here // are exactly the ones that make mDNS silently fail in the wild: // // - bonjour-service is imported LAZILY. A project that has not installed it (or // runs somewhere the native mDNS socket cannot bind) still boots fine; // advertising just becomes a no-op in an "unsupported" state. // - The SRV host is ALWAYS explicitly .local-suffixed. os.hostname() alone can // return a bare name (e.g. "MSI" on Windows), which routes address lookups to // unicast DNS instead of multicast DNS and never resolves. // - Only STABLE metadata goes in the TXT record (platform, app name). Putting // per-restart / per-interface data like an IP in TXT produces conflicting // duplicate records; some clients (notably iOS NWBrowser) then silently never // resolve the service, with no error and no timeout. // - A single service instance is kept at a time; re-advertising (e.g. after a // port change) stops the old one first so the network never sees duplicates. // - Clean shutdown sends TTL=0 "goodbye" packets so stale records do not linger // in client caches for the record's full TTL (can be over an hour). // // Configure via .env: BONJOUR_ENABLED=off | BONJOUR_NAME=... | BONJOUR_TYPE=http import os from "os"; let bonjour = null; // shared mDNS controller (created lazily, one per process) let published = null; // handle to the currently-advertised service, if any let BonjourCtor = null; // cached constructor after the first successful import let selfState = { status: "idle", // idle | advertising | error | unsupported name: null, type: null, port: null, host: null, txt: null, error: null, }; // Import bonjour-service on demand. Returns the constructor, or null if the // package is not installed / cannot load. Handles both the named (Bonjour) and // default export shapes across versions. async function loadBonjour() { if (BonjourCtor) return BonjourCtor; try { const mod = await import("bonjour-service"); BonjourCtor = mod.Bonjour || mod.default || (mod.default && mod.default.Bonjour) || null; return BonjourCtor; } catch { return null; } } // Always .local-suffixed, single-label host so address lookups use mDNS. function localHostname() { const base = (os.hostname() || "device").split(".")[0].trim() || "device"; return base + ".local"; } // Build a network-unique default service name. Two apps scaffolded from the same // skeleton on different machines would otherwise collide on the same name. function defaultServiceName() { // An explicit BONJOUR_NAME is used verbatim — the user owns uniqueness. if (process.env.BONJOUR_NAME) return process.env.BONJOUR_NAME; // Auto name: project name + hostname, so two machines don't collide. const base = process.env.APP_NAME || process.env.npm_package_name || "4bnode-app"; const host = (os.hostname() || "").split(".")[0].replace(/[^a-zA-Z0-9-]/g, ""); return host ? base + " (" + host + ")" : base; } // On by default; opt out with BONJOUR_ENABLED=off. export function isDiscoveryEnabled() { return process.env.BONJOUR_ENABLED !== "off"; } // Snapshot of what this process is currently advertising (read by the dashboard). export function getSelfState() { return { ...selfState, enabled: isDiscoveryEnabled() }; } // Advertise this app on the local network. Safe to call repeatedly; each call // replaces any prior advertisement, so a port change re-publishes cleanly. Must // be called AFTER the HTTP server is actually listening (so the port is bound). export async function startAdvertising({ port, name, type } = {}) { if (!isDiscoveryEnabled()) { selfState = { ...selfState, status: "idle", error: null }; return getSelfState(); } if (!port) return getSelfState(); const Ctor = await loadBonjour(); if (!Ctor) { selfState = { status: "unsupported", name: null, type: null, port: null, host: null, txt: null, error: "bonjour-service not installed", }; console.log( "mDNS: bonjour-service not installed - LAN discovery disabled. Run: npm install bonjour-service to enable.", ); return getSelfState(); } // Replace any previous advertisement so two conflicting records never coexist. await stopAdvertising(); if (!bonjour) bonjour = new Ctor(); const serviceName = name || defaultServiceName(); const serviceType = type || process.env.BONJOUR_TYPE || "http"; const host = localHostname(); const txt = { platform: os.platform(), // stable across restarts - safe for TXT app: process.env.APP_NAME || process.env.npm_package_name || "4bnode-app", }; try { published = bonjour.publish({ name: serviceName, type: serviceType, port, host, txt }); selfState = { status: "advertising", name: serviceName, type: serviceType, port, host, txt, error: null, }; published.on("up", () => { console.log( "mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)", ); }); published.on("error", (err) => { const msg = String((err && err.message) || err); selfState = { ...selfState, status: "error", error: msg }; console.warn( "mDNS: advertising error - " + msg + ". If discovery is not working, check that UDP port 5353 is not blocked by a firewall.", ); }); } catch (err) { selfState = { ...selfState, status: "error", error: String((err && err.message) || err) }; } return getSelfState(); } // Stop advertising and send the TTL=0 goodbye packets. Resolves once the mDNS // responder has flushed them (or after a short safety timeout, so a stuck // responder can never hang process shutdown). export function stopAdvertising() { return new Promise((resolve) => { if (!bonjour) { published = null; return resolve(); } let done = false; const finish = () => { if (done) return; done = true; published = null; if (selfState.status === "advertising") selfState = { ...selfState, status: "idle" }; resolve(); }; try { bonjour.unpublishAll(finish); // constructs + sends goodbye packets const t = setTimeout(finish, 1500); if (t.unref) t.unref(); } catch { finish(); } }); } // Full teardown for process shutdown: goodbye packets, then destroy the socket. export async function destroyDiscovery() { await stopAdvertising(); try { if (bonjour) bonjour.destroy(); } catch {} bonjour = null; } // Browse the LAN for services of a given type. Collects results for timeoutMs // (de-duplicated, excluding our own advertisement), then stops the browser and // resolves. "available" is false when bonjour-service is not installed. export async function browse({ type = "http", timeoutMs = 2500 } = {}) { const Ctor = await loadBonjour(); if (!Ctor) return { available: false, services: [] }; if (!bonjour) bonjour = new Ctor(); return new Promise((resolve) => { const found = new Map(); let browser = null; let settled = false; const finish = () => { if (settled) return; settled = true; try { if (browser && typeof browser.stop === "function") browser.stop(); } catch {} resolve({ available: true, services: Array.from(found.values()) }); }; try { browser = bonjour.find({ type }, (service) => { // Exclude our own advertisement from the peer list. if (published && selfState.name && service.name === selfState.name) return; const key = service.name + "|" + service.host + "|" + service.port; found.set(key, { name: service.name, host: service.host, port: service.port, type: service.type, protocol: service.protocol, addresses: Array.isArray(service.addresses) ? service.addresses : [], txt: service.txt || {}, fqdn: service.fqdn || null, }); }); } catch (err) { settled = true; return resolve({ available: true, services: [], error: String((err && err.message) || err) }); } const t = setTimeout(finish, Math.max(500, Math.min(10000, Number(timeoutMs) || 2500))); if (t.unref) t.unref(); }); }