4bnode 4.1.7 → 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 +89 -0
- package/index.js +39 -3
- package/lib/codegen.js +322 -0
- package/lib/codegen.test.js +55 -0
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +2 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +1 -0
- package/skeleton/.4bnode/assets/js/lib.js +3 -0
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +202 -0
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +2 -1
- package/skeleton/.4bnode/codegen.js +322 -0
- package/skeleton/.4bnode/dev-api.js +94 -5
- package/skeleton/.env.example +10 -0
- package/skeleton/index.js +32 -0
- package/skeleton/package.json +2 -1
- package/skeleton/src/discovery.js +237 -0
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
|
-
|
|
150
|
-
|
|
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
|
+
|
package/lib/codegen.test.js
CHANGED
|
@@ -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.
|
|
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
|
};
|