// server/handlers/discovery.ts — mDNS/DNS-SD local network device discovery
// Scan for POS terminals, printers, and other services on the local network.
// Uses bonjour-service (Zeroconf/mDNS implementation in TypeScript).
import type { SupabaseClient } from "@supabase/supabase-js";
import Bonjour, { type Service } from "bonjour-service";
// ============================================================================
// MODULE-LEVEL STATE
// ============================================================================
/** Single Bonjour instance shared across advertise operations */
let bonjourInstance: Bonjour | null = null;
/** Map of advertised service name -> Service instance for stop/list */
const advertisedServices = new Map<string, Service>();
function getBonjour(): Bonjour {
if (!bonjourInstance) {
bonjourInstance = new Bonjour();
}
return bonjourInstance;
}
/** Common service types to scan when doing a broad discovery */
const COMMON_SERVICE_TYPES = [
"http",
"https",
"printer",
"ipp",
"pos",
"airplay",
"raop",
"smb",
"ftp",
"ssh",
"mqtt",
];
// ============================================================================
// SCAN
// ============================================================================
interface DiscoveredService {
name: string;
type: string;
host: string;
port: number;
addresses: string[] | undefined;
txt: Record<string, unknown> | undefined;
}
/**
* Scan the local network for mDNS services.
* Creates a short-lived Bonjour instance per scan to avoid memory leaks.
*/
async function scanServices(
serviceType: string,
timeoutMs: number
): Promise<DiscoveredService[]> {
return new Promise((resolve, reject) => {
let bonjour: Bonjour;
try {
bonjour = new Bonjour();
} catch (err) {
reject(
new Error(
`mDNS not available on this host. This typically happens in Docker/cloud environments without multicast support. (${err instanceof Error ? err.message : String(err)})`
)
);
return;
}
const services: DiscoveredService[] = [];
const seen = new Set<string>();
// Strip leading underscore and trailing protocol if user passed full form
// e.g. "_http._tcp" -> "http"
let type = serviceType;
if (type.startsWith("_")) type = type.slice(1);
if (type.endsWith("._tcp")) type = type.slice(0, -5);
if (type.endsWith("._udp")) type = type.slice(0, -5);
let browser: ReturnType<typeof bonjour.find>;
try {
browser = bonjour.find({ type });
} catch (err) {
bonjour.destroy();
reject(
new Error(
`Failed to start mDNS browser: ${err instanceof Error ? err.message : String(err)}`
)
);
return;
}
browser.on("up", (service: Service) => {
const key = `${service.name}::${service.type}::${service.port}`;
if (!seen.has(key)) {
seen.add(key);
services.push({
name: service.name,
type: service.type,
host: service.host,
port: service.port,
addresses: service.addresses,
txt: service.txt as Record<string, unknown> | undefined,
});
}
});
const timer = setTimeout(() => {
try {
browser.stop();
bonjour.destroy();
} catch {
// Ignore cleanup errors
}
resolve(services);
}, timeoutMs);
// Safety: if the browser errors out, resolve with whatever we have
browser.on("error", () => {
clearTimeout(timer);
try {
browser.stop();
bonjour.destroy();
} catch {
// Ignore cleanup errors
}
resolve(services);
});
});
}
/**
* Multi-type scan: run parallel scans for common service types.
*/
async function scanAllCommonTypes(
timeoutMs: number
): Promise<DiscoveredService[]> {
const results = await Promise.allSettled(
COMMON_SERVICE_TYPES.map((type) => scanServices(type, timeoutMs))
);
const allServices: DiscoveredService[] = [];
const seen = new Set<string>();
for (const result of results) {
if (result.status === "fulfilled") {
for (const svc of result.value) {
const key = `${svc.name}::${svc.type}::${svc.port}`;
if (!seen.has(key)) {
seen.add(key);
allServices.push(svc);
}
}
}
}
return allServices;
}
// ============================================================================
// ADVERTISE
// ============================================================================
function advertiseService(
name: string,
type: string,
port: number,
txt?: Record<string, string>
): { success: boolean; error?: string } {
if (advertisedServices.has(name)) {
return {
success: false,
error: `Service "${name}" is already being advertised. Stop it first with stop_advertise.`,
};
}
// Strip protocol suffix from type for bonjour-service
let cleanType = type;
if (cleanType.startsWith("_")) cleanType = cleanType.slice(1);
if (cleanType.endsWith("._tcp")) cleanType = cleanType.slice(0, -5);
if (cleanType.endsWith("._udp")) cleanType = cleanType.slice(0, -5);
try {
const bonjour = getBonjour();
const service = bonjour.publish({
name,
type: cleanType,
port,
txt: txt as Record<string, string> | undefined,
});
advertisedServices.set(name, service);
return { success: true };
} catch (err) {
return {
success: false,
error: `Failed to advertise service: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
// ============================================================================
// STOP ADVERTISE
// ============================================================================
function stopAdvertise(name: string): { success: boolean; error?: string } {
const service = advertisedServices.get(name);
if (!service) {
return {
success: false,
error: `No advertised service found with name "${name}". Currently advertised: ${advertisedServices.size === 0 ? "none" : Array.from(advertisedServices.keys()).join(", ")}`,
};
}
try {
if (service.stop) {
service.stop();
}
// The service also has a 'destroyed' flag — set externally isn't needed
// since stop() handles cleanup in bonjour-service
} catch {
// Best-effort cleanup
}
advertisedServices.delete(name);
return { success: true };
}
// ============================================================================
// LIST ADVERTISED
// ============================================================================
function listAdvertised(): {
success: boolean;
data: Array<{ name: string; type: string; port: number; host: string; txt?: unknown }>;
} {
const list = Array.from(advertisedServices.entries()).map(([name, svc]) => ({
name,
type: svc.type,
port: svc.port,
host: svc.host,
txt: svc.txt,
}));
return { success: true, data: list };
}
// ============================================================================
// MAIN HANDLER
// ============================================================================
export async function handleDiscovery(
_sb: SupabaseClient,
args: Record<string, unknown>,
_storeId?: string
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const action = args.action as string;
switch (action) {
case "scan": {
const serviceType = (args.service_type as string) || "";
const timeoutMs = Math.min(
Math.max((args.timeout_ms as number) || 5000, 1000),
30000
); // clamp 1-30s
try {
let services: DiscoveredService[];
if (!serviceType || serviceType === "all") {
services = await scanAllCommonTypes(timeoutMs);
} else {
services = await scanServices(serviceType, timeoutMs);
}
return {
success: true,
data: {
service_type: serviceType || "all (common types)",
timeout_ms: timeoutMs,
count: services.length,
services,
},
};
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: `Scan failed: ${String(err)}`,
};
}
}
case "advertise": {
const name = args.name as string;
const type = args.type as string;
const port = args.port as number;
const txt = args.txt as Record<string, string> | undefined;
if (!name) return { success: false, error: "name is required for advertise" };
if (!type) return { success: false, error: "type is required for advertise (e.g. _http._tcp)" };
if (!port || typeof port !== "number")
return { success: false, error: "port is required for advertise (must be a number)" };
const result = advertiseService(name, type, port, txt);
if (result.success) {
return {
success: true,
data: {
message: `Service "${name}" is now being advertised as ${type} on port ${port}`,
name,
type,
port,
txt: txt || null,
},
};
}
return result;
}
case "stop_advertise": {
const name = args.name as string;
if (!name) return { success: false, error: "name is required for stop_advertise" };
const result = stopAdvertise(name);
if (result.success) {
return {
success: true,
data: { message: `Stopped advertising service "${name}"` },
};
}
return result;
}
case "list_advertised": {
const result = listAdvertised();
return {
success: true,
data: {
count: result.data.length,
services: result.data,
},
};
}
default:
return {
success: false,
error: `Unknown discovery action: "${action}". Valid actions: scan, advertise, stop_advertise, list_advertised`,
};
}
}