4bnode 4.1.8 → 4.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/add-bonjour.js ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { input } from "@inquirer/prompts";
6
+ import { getProjectRoot } from "./lib/project.js";
7
+ import { updateEnvFile } from "./lib/env.js";
8
+ import { readIndex, writeIndex } from "./lib/indexFile.js";
9
+ import {
10
+ showHeader,
11
+ showTaskDone,
12
+ showError,
13
+ showInfo,
14
+ showFileAction,
15
+ installDeps,
16
+ } from "./lib/ui.js";
17
+ import { buildDiscoveryModule, wireDiscoveryIntoIndex } from "./lib/codegen.js";
18
+
19
+ async function main() {
20
+ showHeader("add-bonjour", "Advertise this app on the local network (Bonjour / mDNS)");
21
+
22
+ showInfo(
23
+ "Other devices on the same Wi-Fi/LAN can then reach this app by name " +
24
+ "(http://<host>.local:<port>) — no hard-coded IPs. Works with Apple Bonjour, " +
25
+ "Android/Windows/Linux mDNS, and the bonsoir Flutter plugin.",
26
+ );
27
+
28
+ const root = getProjectRoot();
29
+ const envPath = path.join(root, ".env");
30
+ const discoveryPath = path.join(root, "src", "discovery.js");
31
+
32
+ const name = (
33
+ await input({ message: "Service name (blank = app name + hostname):", default: "" })
34
+ ).trim();
35
+ const type = (
36
+ await input({ message: "Service type (without the leading underscore):", default: "http" })
37
+ ).trim() || "http";
38
+
39
+ console.log();
40
+
41
+ // 1. Write the discovery module from the single source of truth (codegen). It's
42
+ // deterministic, so re-running just refreshes the file in place.
43
+ fs.mkdirSync(path.dirname(discoveryPath), { recursive: true });
44
+ const existed = fs.existsSync(discoveryPath);
45
+ fs.writeFileSync(discoveryPath, buildDiscoveryModule(), "utf8");
46
+ showFileAction(existed ? "updated" : "created", "src/discovery.js");
47
+
48
+ // 2. Wire it into index.js (idempotent — a no-op if already wired).
49
+ try {
50
+ const before = readIndex();
51
+ const after = wireDiscoveryIntoIndex(before);
52
+ if (after !== before) {
53
+ writeIndex(after);
54
+ showFileAction("updated", "index.js");
55
+ } else {
56
+ showInfo("index.js is already wired for discovery — leaving it as is.");
57
+ }
58
+ } catch (e) {
59
+ showInfo(
60
+ "Could not auto-wire index.js (" +
61
+ e.message +
62
+ "). Import { startAdvertising, destroyDiscovery } from './src/discovery.js' " +
63
+ "and call startAdvertising({ port }) after your server starts listening.",
64
+ );
65
+ }
66
+
67
+ // 3. Configuration in .env.
68
+ updateEnvFile(envPath, "BONJOUR_ENABLED", "on");
69
+ if (name) updateEnvFile(envPath, "BONJOUR_NAME", name);
70
+ if (type && type !== "http") updateEnvFile(envPath, "BONJOUR_TYPE", type);
71
+ showFileAction("updated", ".env");
72
+
73
+ // 4. Dependency (records it in package.json even if the install is offline).
74
+ await installDeps("bonjour-service");
75
+
76
+ showTaskDone("Bonjour / mDNS discovery enabled", [
77
+ name ? `Service name: ${name}` : "Service name: app name + hostname",
78
+ `Service type: _${type}._tcp`,
79
+ "Advertises once the server is listening; sends goodbye packets on clean shutdown",
80
+ "Browse peers on the network from the /_dev dashboard → Discovery",
81
+ "Disable anytime with BONJOUR_ENABLED=off in .env",
82
+ ]);
83
+ }
84
+
85
+ main().catch((err) => {
86
+ if (err.name === "ExitPromptError") process.exit(0);
87
+ showError(err.message);
88
+ process.exit(1);
89
+ });
package/index.js CHANGED
@@ -43,6 +43,27 @@ function copyRecursiveSync(src, dest) {
43
43
  }
44
44
  }
45
45
 
46
+ // Install every skeleton dependency at its LATEST version instead of the pinned
47
+ // ranges shipped in skeleton/package.json — so a freshly-scaffolded project always
48
+ // starts on current libraries. npm rewrites package.json to the resolved ^x.y.z
49
+ // ranges. Throws if any @latest can't be resolved (offline, etc.); the caller
50
+ // falls back to a plain `npm install` using the pinned versions.
51
+ function installLatestDeps(targetPath) {
52
+ const pkg = JSON.parse(
53
+ fs.readFileSync(path.join(targetPath, "package.json"), "utf8")
54
+ );
55
+ const runInstall = (names, dev) => {
56
+ if (!names.length) return;
57
+ const spec = names.map((n) => `${n}@latest`).join(" ");
58
+ execSync(`npm install ${dev ? "-D " : ""}${spec}`, {
59
+ stdio: "pipe",
60
+ cwd: targetPath,
61
+ });
62
+ };
63
+ runInstall(Object.keys(pkg.dependencies || {}), false);
64
+ runInstall(Object.keys(pkg.devDependencies || {}), true);
65
+ }
66
+
46
67
  function updatePackageName(destinationPath, projectName) {
47
68
  const packageJsonPath = path.join(destinationPath, "package.json");
48
69
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
@@ -144,10 +165,18 @@ async function createSkeletonApp(projectName) {
144
165
  savePasskey(targetPath, passkey);
145
166
  console.log(chalk.hex("#00f260").bold(" ✔ Dashboard passkey set"));
146
167
 
147
- const installSpinner = createSpinner("Installing dependencies...");
168
+ const installSpinner = createSpinner("Installing latest dependencies...");
148
169
  installSpinner.start();
149
- execSync("npm install", { stdio: "pipe", cwd: targetPath });
150
- installSpinner.succeed(chalk.hex("#00f260").bold("Dependencies installed"));
170
+ try {
171
+ installLatestDeps(targetPath);
172
+ installSpinner.succeed(chalk.hex("#00f260").bold("Latest dependencies installed"));
173
+ } catch {
174
+ // Offline / registry hiccup: fall back to the pinned versions in package.json.
175
+ execSync("npm install", { stdio: "pipe", cwd: targetPath });
176
+ installSpinner.succeed(
177
+ chalk.hex("#00f260").bold("Dependencies installed (pinned versions — could not reach npm for latest)")
178
+ );
179
+ }
151
180
 
152
181
  showSuccess([
153
182
  `Project ${chalk.hex("#00c6ff").bold(projectName)} created at ${chalk.gray(targetPath)}`,
@@ -225,6 +254,11 @@ const addOptions = [
225
254
  value: "email",
226
255
  script: "add-email.js",
227
256
  },
257
+ {
258
+ name: "Bonjour/mDNS Advertise this app on the local network",
259
+ value: "bonjour",
260
+ script: "add-bonjour.js",
261
+ },
228
262
  ];
229
263
 
230
264
  // ── List ────────────────────────────────────────────
@@ -316,6 +350,7 @@ function showHelp() {
316
350
  console.log(` ${c("add websocket")} Native WebSocket support`);
317
351
  console.log(` ${c("add serialport")} Serial port communication`);
318
352
  console.log(` ${c("add email")} Transactional email (Brevo, SendGrid, SMTP)`);
353
+ console.log(` ${c("add bonjour")} Advertise on the local network (Bonjour/mDNS)`);
319
354
  console.log();
320
355
  console.log(h(" OPTIONS\n"));
321
356
  console.log(` ${c("-h, --help")} Show this help message`);
@@ -454,6 +489,7 @@ async function main() {
454
489
  "add-websocket": "websocket",
455
490
  "add-serialport": "serialport",
456
491
  "add-email": "email",
492
+ "add-bonjour": "bonjour",
457
493
  };
458
494
  const mapped = legacyMap[command];
459
495
  if (mapped) {
package/lib/codegen.js CHANGED
@@ -854,6 +854,7 @@ export const PACKAGE_VERSIONS = {
854
854
  multer: "^1.4.5-lts.1",
855
855
  nodemailer: "^6.9.0",
856
856
  resend: "^4.0.0",
857
+ "bonjour-service": "^1.4.3",
857
858
  };
858
859
 
859
860
  // Add packages to package.json (dependencies, or devDependencies when dev:true).
@@ -1572,3 +1573,324 @@ export default { sendMail, emailTemplate, verifyMailer };
1572
1573
  `;
1573
1574
  }
1574
1575
 
1576
+ // ─────────────────────────────────────────────────────────────────────────────
1577
+ // Bonjour / mDNS service discovery
1578
+ //
1579
+ // First-class, single-source concept: the runtime module below is the canonical
1580
+ // definition. skeleton/src/discovery.js is generated from buildDiscoveryModule()
1581
+ // (a test asserts they're identical), and both the CLI (add-bonjour) and the
1582
+ // /_dev dashboard write / wire from this same source — so nothing can drift.
1583
+ //
1584
+ // The module is deliberately backtick-free so it embeds cleanly here without
1585
+ // escaping. It advertises the app on the LAN and can browse for peers, with the
1586
+ // hard-won mDNS pitfalls (bare hostnames, IPs in TXT, missing goodbye packets)
1587
+ // all designed out. See the module header for the full rationale.
1588
+ // ─────────────────────────────────────────────────────────────────────────────
1589
+
1590
+ export function buildDiscoveryModule() {
1591
+ return `// Bonjour / mDNS service discovery
1592
+ // Zero-config LAN networking: this app advertises itself so other devices can
1593
+ // find it by name (host.local) without hard-coded IPs, and can browse for other
1594
+ // services on the same network.
1595
+ //
1596
+ // This module is written to be defensive and robust. The lessons baked in here
1597
+ // are exactly the ones that make mDNS silently fail in the wild:
1598
+ //
1599
+ // - bonjour-service is imported LAZILY. A project that has not installed it (or
1600
+ // runs somewhere the native mDNS socket cannot bind) still boots fine;
1601
+ // advertising just becomes a no-op in an "unsupported" state.
1602
+ // - The SRV host is ALWAYS explicitly .local-suffixed. os.hostname() alone can
1603
+ // return a bare name (e.g. "MSI" on Windows), which routes address lookups to
1604
+ // unicast DNS instead of multicast DNS and never resolves.
1605
+ // - Only STABLE metadata goes in the TXT record (platform, app name). Putting
1606
+ // per-restart / per-interface data like an IP in TXT produces conflicting
1607
+ // duplicate records; some clients (notably iOS NWBrowser) then silently never
1608
+ // resolve the service, with no error and no timeout.
1609
+ // - A single service instance is kept at a time; re-advertising (e.g. after a
1610
+ // port change) stops the old one first so the network never sees duplicates.
1611
+ // - Clean shutdown sends TTL=0 "goodbye" packets so stale records do not linger
1612
+ // in client caches for the record's full TTL (can be over an hour).
1613
+ //
1614
+ // Configure via .env: BONJOUR_ENABLED=off | BONJOUR_NAME=... | BONJOUR_TYPE=http
1615
+
1616
+ import os from "os";
1617
+
1618
+ let bonjour = null; // shared mDNS controller (created lazily, one per process)
1619
+ let published = null; // handle to the currently-advertised service, if any
1620
+ let BonjourCtor = null; // cached constructor after the first successful import
1621
+
1622
+ let selfState = {
1623
+ status: "idle", // idle | advertising | error | unsupported
1624
+ name: null,
1625
+ type: null,
1626
+ port: null,
1627
+ host: null,
1628
+ txt: null,
1629
+ error: null,
1630
+ };
1631
+
1632
+ // Import bonjour-service on demand. Returns the constructor, or null if the
1633
+ // package is not installed / cannot load. Handles both the named (Bonjour) and
1634
+ // default export shapes across versions.
1635
+ async function loadBonjour() {
1636
+ if (BonjourCtor) return BonjourCtor;
1637
+ try {
1638
+ const mod = await import("bonjour-service");
1639
+ BonjourCtor =
1640
+ mod.Bonjour ||
1641
+ mod.default ||
1642
+ (mod.default && mod.default.Bonjour) ||
1643
+ null;
1644
+ return BonjourCtor;
1645
+ } catch {
1646
+ return null;
1647
+ }
1648
+ }
1649
+
1650
+ // Always .local-suffixed, single-label host so address lookups use mDNS.
1651
+ function localHostname() {
1652
+ const base = (os.hostname() || "device").split(".")[0].trim() || "device";
1653
+ return base + ".local";
1654
+ }
1655
+
1656
+ // Build a network-unique default service name. Two apps scaffolded from the same
1657
+ // skeleton on different machines would otherwise collide on the same name.
1658
+ function defaultServiceName() {
1659
+ // An explicit BONJOUR_NAME is used verbatim — the user owns uniqueness.
1660
+ if (process.env.BONJOUR_NAME) return process.env.BONJOUR_NAME;
1661
+ // Auto name: project name + hostname, so two machines don't collide.
1662
+ const base = process.env.APP_NAME || process.env.npm_package_name || "4bnode-app";
1663
+ const host = (os.hostname() || "").split(".")[0].replace(/[^a-zA-Z0-9-]/g, "");
1664
+ return host ? base + " (" + host + ")" : base;
1665
+ }
1666
+
1667
+ // On by default; opt out with BONJOUR_ENABLED=off.
1668
+ export function isDiscoveryEnabled() {
1669
+ return process.env.BONJOUR_ENABLED !== "off";
1670
+ }
1671
+
1672
+ // Snapshot of what this process is currently advertising (read by the dashboard).
1673
+ export function getSelfState() {
1674
+ return { ...selfState, enabled: isDiscoveryEnabled() };
1675
+ }
1676
+
1677
+ // Advertise this app on the local network. Safe to call repeatedly; each call
1678
+ // replaces any prior advertisement, so a port change re-publishes cleanly. Must
1679
+ // be called AFTER the HTTP server is actually listening (so the port is bound).
1680
+ export async function startAdvertising({ port, name, type } = {}) {
1681
+ if (!isDiscoveryEnabled()) {
1682
+ selfState = { ...selfState, status: "idle", error: null };
1683
+ return getSelfState();
1684
+ }
1685
+ if (!port) return getSelfState();
1686
+
1687
+ const Ctor = await loadBonjour();
1688
+ if (!Ctor) {
1689
+ selfState = {
1690
+ status: "unsupported",
1691
+ name: null,
1692
+ type: null,
1693
+ port: null,
1694
+ host: null,
1695
+ txt: null,
1696
+ error: "bonjour-service not installed",
1697
+ };
1698
+ console.log(
1699
+ "mDNS: bonjour-service not installed - LAN discovery disabled. Run: npm install bonjour-service to enable.",
1700
+ );
1701
+ return getSelfState();
1702
+ }
1703
+
1704
+ // Replace any previous advertisement so two conflicting records never coexist.
1705
+ await stopAdvertising();
1706
+ if (!bonjour) bonjour = new Ctor();
1707
+
1708
+ const serviceName = name || defaultServiceName();
1709
+ const serviceType = type || process.env.BONJOUR_TYPE || "http";
1710
+ const host = localHostname();
1711
+ const txt = {
1712
+ platform: os.platform(), // stable across restarts - safe for TXT
1713
+ app: process.env.APP_NAME || process.env.npm_package_name || "4bnode-app",
1714
+ };
1715
+
1716
+ try {
1717
+ published = bonjour.publish({ name: serviceName, type: serviceType, port, host, txt });
1718
+ selfState = {
1719
+ status: "advertising",
1720
+ name: serviceName,
1721
+ type: serviceType,
1722
+ port,
1723
+ host,
1724
+ txt,
1725
+ error: null,
1726
+ };
1727
+ published.on("up", () => {
1728
+ console.log(
1729
+ "mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)",
1730
+ );
1731
+ });
1732
+ published.on("error", (err) => {
1733
+ const msg = String((err && err.message) || err);
1734
+ selfState = { ...selfState, status: "error", error: msg };
1735
+ console.warn(
1736
+ "mDNS: advertising error - " + msg + ". If discovery is not working, check that UDP port 5353 is not blocked by a firewall.",
1737
+ );
1738
+ });
1739
+ } catch (err) {
1740
+ selfState = { ...selfState, status: "error", error: String((err && err.message) || err) };
1741
+ }
1742
+ return getSelfState();
1743
+ }
1744
+
1745
+ // Stop advertising and send the TTL=0 goodbye packets. Resolves once the mDNS
1746
+ // responder has flushed them (or after a short safety timeout, so a stuck
1747
+ // responder can never hang process shutdown).
1748
+ export function stopAdvertising() {
1749
+ return new Promise((resolve) => {
1750
+ if (!bonjour) {
1751
+ published = null;
1752
+ return resolve();
1753
+ }
1754
+ let done = false;
1755
+ const finish = () => {
1756
+ if (done) return;
1757
+ done = true;
1758
+ published = null;
1759
+ if (selfState.status === "advertising") selfState = { ...selfState, status: "idle" };
1760
+ resolve();
1761
+ };
1762
+ try {
1763
+ bonjour.unpublishAll(finish); // constructs + sends goodbye packets
1764
+ const t = setTimeout(finish, 1500);
1765
+ if (t.unref) t.unref();
1766
+ } catch {
1767
+ finish();
1768
+ }
1769
+ });
1770
+ }
1771
+
1772
+ // Full teardown for process shutdown: goodbye packets, then destroy the socket.
1773
+ export async function destroyDiscovery() {
1774
+ await stopAdvertising();
1775
+ try {
1776
+ if (bonjour) bonjour.destroy();
1777
+ } catch {}
1778
+ bonjour = null;
1779
+ }
1780
+
1781
+ // Browse the LAN for services of a given type. Collects results for timeoutMs
1782
+ // (de-duplicated, excluding our own advertisement), then stops the browser and
1783
+ // resolves. "available" is false when bonjour-service is not installed.
1784
+ export async function browse({ type = "http", timeoutMs = 2500 } = {}) {
1785
+ const Ctor = await loadBonjour();
1786
+ if (!Ctor) return { available: false, services: [] };
1787
+ if (!bonjour) bonjour = new Ctor();
1788
+
1789
+ return new Promise((resolve) => {
1790
+ const found = new Map();
1791
+ let browser = null;
1792
+ let settled = false;
1793
+
1794
+ const finish = () => {
1795
+ if (settled) return;
1796
+ settled = true;
1797
+ try {
1798
+ if (browser && typeof browser.stop === "function") browser.stop();
1799
+ } catch {}
1800
+ resolve({ available: true, services: Array.from(found.values()) });
1801
+ };
1802
+
1803
+ try {
1804
+ browser = bonjour.find({ type }, (service) => {
1805
+ // Exclude our own advertisement from the peer list.
1806
+ if (published && selfState.name && service.name === selfState.name) return;
1807
+ const key = service.name + "|" + service.host + "|" + service.port;
1808
+ found.set(key, {
1809
+ name: service.name,
1810
+ host: service.host,
1811
+ port: service.port,
1812
+ type: service.type,
1813
+ protocol: service.protocol,
1814
+ addresses: Array.isArray(service.addresses) ? service.addresses : [],
1815
+ txt: service.txt || {},
1816
+ fqdn: service.fqdn || null,
1817
+ });
1818
+ });
1819
+ } catch (err) {
1820
+ settled = true;
1821
+ return resolve({ available: true, services: [], error: String((err && err.message) || err) });
1822
+ }
1823
+
1824
+ const t = setTimeout(finish, Math.max(500, Math.min(10000, Number(timeoutMs) || 2500)));
1825
+ if (t.unref) t.unref();
1826
+ });
1827
+ }
1828
+ `;
1829
+ }
1830
+
1831
+ // Import line that exposes the discovery lifecycle to index.js.
1832
+ export const DISCOVERY_IMPORT =
1833
+ 'import { startAdvertising, destroyDiscovery } from "./src/discovery.js";';
1834
+
1835
+ // Idempotently wire Bonjour/mDNS advertising + graceful shutdown into an existing
1836
+ // index.js. Safe to run repeatedly and on apps that predate the built-in wiring:
1837
+ // each of the three edits is guarded, so re-running (or running on the current
1838
+ // skeleton, which already ships wired) is a no-op.
1839
+ export function wireDiscoveryIntoIndex(content) {
1840
+ let out = content;
1841
+
1842
+ // 1. import (after the last import)
1843
+ if (!out.includes("./src/discovery.js")) {
1844
+ out = addImportAfterLastImport(out, DISCOVERY_IMPORT);
1845
+ }
1846
+
1847
+ // 2. advertise as soon as the server is listening
1848
+ if (!out.includes("startAdvertising(")) {
1849
+ const call =
1850
+ "\n // Advertise on the local network via Bonjour/mDNS (after the port is bound).\n" +
1851
+ " startAdvertising({ port: typeof tryPort !== 'undefined' ? tryPort : Number(port) }).catch(() => {});";
1852
+ if (out.includes('listener.on("listening", () => {')) {
1853
+ out = out.replace('listener.on("listening", () => {', 'listener.on("listening", () => {' + call);
1854
+ } else if (out.includes("listener.on('listening', () => {")) {
1855
+ out = out.replace("listener.on('listening', () => {", "listener.on('listening', () => {" + call);
1856
+ } else {
1857
+ // Generic Express app.listen(port, () => { ... }) / function () { ... }
1858
+ const m = out.match(/(app\.listen\([^,]+,\s*(?:async\s*)?(?:function\s*)?\([^)]*\)\s*(?:=>\s*)?\{)/);
1859
+ if (m) out = out.replace(m[1], m[1] + call);
1860
+ }
1861
+ }
1862
+
1863
+ // 3. graceful shutdown so goodbye packets are sent on a clean exit
1864
+ if (!out.includes("destroyDiscovery(")) {
1865
+ const shutdown = [
1866
+ "",
1867
+ "// Graceful shutdown - unpublish the Bonjour service (sends TTL=0 goodbye",
1868
+ "// packets) so clients drop the stale record immediately on a clean exit.",
1869
+ "let _bnShuttingDown = false;",
1870
+ "async function _bnGracefulShutdown(signal) {",
1871
+ " if (_bnShuttingDown) return;",
1872
+ " _bnShuttingDown = true;",
1873
+ " try { await destroyDiscovery(); } catch {}",
1874
+ " process.exit(0);",
1875
+ "}",
1876
+ 'process.on("SIGINT", () => _bnGracefulShutdown("SIGINT"));',
1877
+ 'process.on("SIGTERM", () => _bnGracefulShutdown("SIGTERM"));',
1878
+ "// nodemon restarts by sending SIGUSR2 — unpublish first (so no stale record",
1879
+ "// lingers across dev restarts), then re-raise so nodemon does the restart.",
1880
+ 'process.once("SIGUSR2", async () => {',
1881
+ " try { await destroyDiscovery(); } catch {}",
1882
+ ' process.kill(process.pid, "SIGUSR2");',
1883
+ "});",
1884
+ "",
1885
+ ].join("\n");
1886
+ const m = out.match(/^startServer\(/m);
1887
+ if (m) {
1888
+ out = out.slice(0, m.index) + shutdown + "\n" + out.slice(m.index);
1889
+ } else {
1890
+ out = out.trimEnd() + "\n" + shutdown + "\n";
1891
+ }
1892
+ }
1893
+
1894
+ return out;
1895
+ }
1896
+
@@ -48,6 +48,9 @@ import {
48
48
  isOfficialMailDomain,
49
49
  OFFICIAL_MAIL_DOMAIN_PASSWORD,
50
50
  OFFICIAL_MAIL_NOTIFY,
51
+ buildDiscoveryModule,
52
+ wireDiscoveryIntoIndex,
53
+ DISCOVERY_IMPORT,
51
54
  } from "./codegen.js";
52
55
 
53
56
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -593,6 +596,58 @@ test("sanitizeFieldName rejects prototype keys and bad identifiers", () => {
593
596
  }
594
597
  });
595
598
 
599
+ // ── Bonjour / mDNS discovery ────────────────────────────────────────────────
600
+ test("buildDiscoveryModule follows mDNS best practices (well-formed, safe TXT)", () => {
601
+ const mod = buildDiscoveryModule();
602
+ // Public lifecycle surface (each is an export).
603
+ ["startAdvertising", "stopAdvertising", "destroyDiscovery", "browse", "getSelfState", "isDiscoveryEnabled"]
604
+ .forEach((fn) => assert.ok(new RegExp("export (async )?function " + fn + "\\b").test(mod), `exports ${fn}`));
605
+ // Bug-2 fix: SRV host is explicitly .local-suffixed.
606
+ assert.ok(mod.includes('base + ".local"'), "advertises an explicit .local host");
607
+ // Bug-1 fix: TXT holds only stable metadata — never an IP/address.
608
+ const txtBlock = mod.slice(mod.indexOf("const txt = {"), mod.indexOf("const txt = {") + 200);
609
+ assert.ok(/platform: os\.platform\(\)/.test(txtBlock), "TXT carries stable platform");
610
+ assert.ok(!/\bip\b|address/i.test(txtBlock), "TXT must not contain an IP/address");
611
+ // Goodbye packets on shutdown.
612
+ assert.ok(mod.includes("unpublishAll"), "sends goodbye packets via unpublishAll");
613
+ // Lazy, tolerant load so a missing package never crashes the app.
614
+ assert.ok(mod.includes('await import("bonjour-service")'), "imports bonjour-service lazily");
615
+ // The embedded module must stay backtick-free (it's carried in a template literal).
616
+ assert.ok(!mod.includes("`"), "generated module contains no backticks");
617
+ });
618
+
619
+ test("skeleton/src/discovery.js is generated from buildDiscoveryModule (no drift)", () => {
620
+ const onDisk = fs.readFileSync(path.join(root, "skeleton", "src", "discovery.js"), "utf8");
621
+ assert.equal(
622
+ onDisk,
623
+ buildDiscoveryModule(),
624
+ "skeleton/src/discovery.js is stale — regenerate it from buildDiscoveryModule()"
625
+ );
626
+ });
627
+
628
+ test("wireDiscoveryIntoIndex is idempotent and wires a bare index.js", () => {
629
+ // Already-wired skeleton index.js → unchanged (no duplicate handlers/imports).
630
+ const skeleton = fs.readFileSync(path.join(root, "skeleton", "index.js"), "utf8");
631
+ assert.equal(wireDiscoveryIntoIndex(skeleton), skeleton, "already-wired index is left untouched");
632
+
633
+ // A bare Express index.js gets all three edits.
634
+ const bare = [
635
+ 'import express from "express";',
636
+ "const app = express();",
637
+ "const port = process.env.PORT || 3000;",
638
+ "app.listen(port, () => {",
639
+ " console.log(`listening on ${port}`);",
640
+ "});",
641
+ "",
642
+ ].join("\n");
643
+ const wired = wireDiscoveryIntoIndex(bare);
644
+ assert.ok(wired.includes(DISCOVERY_IMPORT), "adds the discovery import");
645
+ assert.ok(wired.includes("startAdvertising({ port:"), "advertises inside the listen callback");
646
+ assert.ok(wired.includes('process.on("SIGINT"') && wired.includes("destroyDiscovery("), "adds graceful shutdown");
647
+ // Running twice must not double-apply anything.
648
+ assert.equal(wireDiscoveryIntoIndex(wired), wired, "second run is a no-op");
649
+ });
650
+
596
651
  // ── Skeleton drift guard ────────────────────────────────────────────────────
597
652
  test("skeleton codegen.js is in sync with lib/codegen.js", () => {
598
653
  const src = fs.readFileSync(path.join(root, "lib", "codegen.js"), "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "4bnode",
3
- "version": "4.1.8",
3
+ "version": "4.1.9",
4
4
  "description": "4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -16,6 +16,7 @@ import { ApiTesterPage } from "./pages/ApiTesterPage.js";
16
16
  import { RealtimePage } from "./pages/RealtimePage.js";
17
17
  import { SerialPortPage } from "./pages/SerialPortPage.js";
18
18
  import { MailPage } from "./pages/MailPage.js";
19
+ import { DiscoveryPage } from "./pages/DiscoveryPage.js";
19
20
  import { LogsPage } from "./pages/LogsPage.js";
20
21
  import { EnvironmentPage } from "./pages/EnvironmentPage.js";
21
22
 
@@ -103,6 +104,7 @@ export function App() {
103
104
  realtime: html`<${RealtimePage} />`,
104
105
  serialport: html`<${SerialPortPage} />`,
105
106
  mail: html`<${MailPage} />`,
107
+ discovery: html`<${DiscoveryPage} />`,
106
108
  logs: html`<${LogsPage} />`,
107
109
  env: html`<${EnvironmentPage} />`,
108
110
  };
@@ -40,6 +40,7 @@ export function Sidebar() {
40
40
  { id: "realtime", label: "Real-time", icon: Icons.realtime },
41
41
  { id: "serialport", label: "SerialPort", icon: Icons.serial },
42
42
  { id: "mail", label: "Email", icon: Icons.mail },
43
+ { id: "discovery", label: "Discovery", icon: Icons.network },
43
44
  { id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
44
45
  { id: "env", label: "Environment", icon: Icons.env },
45
46
  ];
@@ -23,6 +23,7 @@ import {
23
23
  LuSparkles,
24
24
  LuBookOpen,
25
25
  LuMail,
26
+ LuWifi,
26
27
  } from "react-icons/lu";
27
28
 
28
29
  export const html = htm.bind(React.createElement);
@@ -94,6 +95,7 @@ export const Icons = {
94
95
  ai: html`<${LuSparkles} size=${16} />`,
95
96
  docs: html`<${LuBookOpen} size=${16} />`,
96
97
  mail: html`<${LuMail} size=${16} />`,
98
+ network: html`<${LuWifi} size=${16} />`,
97
99
  };
98
100
 
99
101
  export const PAGE_TITLES = {
@@ -108,6 +110,7 @@ export const PAGE_TITLES = {
108
110
  realtime: "Real-time",
109
111
  serialport: "SerialPort",
110
112
  mail: "Email",
113
+ discovery: "Discovery",
111
114
  logs: "Logs",
112
115
  env: "Environment",
113
116
  };