4bnode 4.1.8 → 4.2.0
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 +231 -0
- package/skeleton/.4bnode/codegen.js +322 -0
- package/skeleton/.4bnode/dev-api.js +88 -0
- 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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { LuWifi, LuRefreshCw } from "react-icons/lu";
|
|
3
|
+
import { html, api, copyToClipboard } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
const STATUS_BADGE = {
|
|
7
|
+
advertising: { cls: "badge-success", label: "Advertising" },
|
|
8
|
+
idle: { cls: "badge-muted", label: "Idle" },
|
|
9
|
+
error: { cls: "badge-error", label: "Error" },
|
|
10
|
+
unsupported: { cls: "badge-warning", label: "Not installed" },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function DiscoveryPage() {
|
|
14
|
+
const { toast } = useApp();
|
|
15
|
+
const [info, setInfo] = useState(null);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [saving, setSaving] = useState(false);
|
|
18
|
+
const [toggling, setToggling] = useState(false);
|
|
19
|
+
const [name, setName] = useState("");
|
|
20
|
+
const [type, setType] = useState("http");
|
|
21
|
+
const [enabled, setEnabled] = useState(true);
|
|
22
|
+
|
|
23
|
+
const [browseType, setBrowseType] = useState("http");
|
|
24
|
+
const [services, setServices] = useState(null);
|
|
25
|
+
const [browsing, setBrowsing] = useState(false);
|
|
26
|
+
|
|
27
|
+
async function loadInfo() {
|
|
28
|
+
setLoading(true);
|
|
29
|
+
try {
|
|
30
|
+
const r = await api("/discovery");
|
|
31
|
+
setInfo(r);
|
|
32
|
+
setName((r.config && r.config.name) || "");
|
|
33
|
+
setType((r.config && r.config.type) || "http");
|
|
34
|
+
setEnabled(r.enabled !== false);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
toast(err.message, "error");
|
|
37
|
+
}
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
loadInfo();
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
// Flip Bonjour on/off immediately — no "Save" step. The dashboard runs in the
|
|
46
|
+
// same process as the advertiser, so the server re-publishes / unpublishes live
|
|
47
|
+
// and persists BONJOUR_ENABLED to .env in one call.
|
|
48
|
+
async function handleToggle(e) {
|
|
49
|
+
const next = e.target.checked;
|
|
50
|
+
setEnabled(next); // optimistic
|
|
51
|
+
setToggling(true);
|
|
52
|
+
try {
|
|
53
|
+
await api("/discovery/config", { method: "POST", body: { enabled: next } });
|
|
54
|
+
toast(next ? "Bonjour enabled" : "Bonjour disabled", "success");
|
|
55
|
+
loadInfo();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setEnabled(!next); // revert on failure
|
|
58
|
+
toast(err.message, "error");
|
|
59
|
+
}
|
|
60
|
+
setToggling(false);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleSave() {
|
|
64
|
+
setSaving(true);
|
|
65
|
+
try {
|
|
66
|
+
const r = await api("/discovery/config", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
body: { enabled, name, type },
|
|
69
|
+
});
|
|
70
|
+
toast(r.message || "Saved", "success");
|
|
71
|
+
loadInfo();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
toast(err.message, "error");
|
|
74
|
+
}
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleBrowse() {
|
|
79
|
+
setBrowsing(true);
|
|
80
|
+
setServices(null);
|
|
81
|
+
try {
|
|
82
|
+
const r = await api("/discovery/browse?type=" + encodeURIComponent(browseType || "http"));
|
|
83
|
+
if (!r.available) {
|
|
84
|
+
toast("bonjour-service is not installed", "error");
|
|
85
|
+
setServices([]);
|
|
86
|
+
} else {
|
|
87
|
+
setServices(r.services || []);
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
toast(err.message, "error");
|
|
91
|
+
setServices([]);
|
|
92
|
+
}
|
|
93
|
+
setBrowsing(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const self = info && info.self;
|
|
97
|
+
const available = info && info.available;
|
|
98
|
+
const status = self ? self.status : "idle";
|
|
99
|
+
const badge = STATUS_BADGE[status] || STATUS_BADGE.idle;
|
|
100
|
+
|
|
101
|
+
return html`<div>
|
|
102
|
+
<div className="flex items-center justify-between mb-4">
|
|
103
|
+
<div>
|
|
104
|
+
<div className="card-title">Discovery</div>
|
|
105
|
+
<div className="card-subtitle">
|
|
106
|
+
Bonjour / mDNS — advertise this app and find others on your network
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<button className="btn btn-ghost btn-sm" onClick=${loadInfo} disabled=${loading}>
|
|
110
|
+
<span className="sidebar-icon"><${LuRefreshCw} size=${14} /></span> Refresh
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
${loading && !info
|
|
115
|
+
? html`<div className="card"><div className="empty-state" style=${{ padding: "40px" }}><p>Loading…</p></div></div>`
|
|
116
|
+
: html`<div style=${{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))", gap: "20px", alignItems: "start" }}>
|
|
117
|
+
<!-- This app -->
|
|
118
|
+
<div className="card">
|
|
119
|
+
<div className="flex items-center justify-between mb-3">
|
|
120
|
+
<div className="card-title">This app</div>
|
|
121
|
+
<span className=${`badge ${badge.cls}`}>${badge.label}</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
${!available
|
|
125
|
+
? html`<div>
|
|
126
|
+
<p className="text-sm text-muted mb-2">
|
|
127
|
+
Local network discovery needs the <code>bonjour-service</code> package.
|
|
128
|
+
Install it, then restart the app:
|
|
129
|
+
</p>
|
|
130
|
+
<div className="code-block" style=${{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
|
131
|
+
<code>npm install bonjour-service</code>
|
|
132
|
+
<button className="btn btn-ghost btn-sm" onClick=${() => { copyToClipboard("npm install bonjour-service"); toast("Copied", "success"); }}>Copy</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>`
|
|
135
|
+
: html`<div>
|
|
136
|
+
<!-- Enable / disable — applies instantly, no restart -->
|
|
137
|
+
<div style=${{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 12px", border: "1px solid var(--border)", borderRadius: "8px", marginBottom: "14px" }}>
|
|
138
|
+
<div>
|
|
139
|
+
<div style=${{ fontSize: "13px", fontWeight: 600 }}>Advertise on local network</div>
|
|
140
|
+
<div className="text-muted" style=${{ fontSize: "12px" }}>
|
|
141
|
+
${enabled ? "On — this app is discoverable via Bonjour/mDNS" : "Off — not broadcasting on the network"}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
<label className="form-toggle">
|
|
145
|
+
<input type="checkbox" checked=${enabled} disabled=${toggling} onChange=${handleToggle} />
|
|
146
|
+
<span className="slider"></span>
|
|
147
|
+
</label>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
${status === "advertising"
|
|
151
|
+
? html`<div style=${{ display: "grid", gridTemplateColumns: "90px 1fr", gap: "6px 12px", fontSize: "13px", marginBottom: "12px" }}>
|
|
152
|
+
<span className="text-muted">Name</span><span>${self.name}</span>
|
|
153
|
+
<span className="text-muted">Address</span>
|
|
154
|
+
<span style=${{ fontFamily: "monospace" }}>
|
|
155
|
+
<a href=${`http://${self.host}:${self.port}`} target="_blank" rel="noreferrer">${self.host}:${self.port}</a>
|
|
156
|
+
</span>
|
|
157
|
+
<span className="text-muted">Service</span><span style=${{ fontFamily: "monospace" }}>_${self.type}._tcp.local</span>
|
|
158
|
+
${self.txt ? Object.entries(self.txt).map(([k, v]) => html`
|
|
159
|
+
<span key=${k} className="text-muted">txt · ${k}</span><span>${String(v)}</span>`) : null}
|
|
160
|
+
</div>`
|
|
161
|
+
: html`<p className="text-sm text-muted mb-2">
|
|
162
|
+
${status === "error"
|
|
163
|
+
? html`Advertising failed: <span style=${{ color: "var(--danger, #e5484d)" }}>${self.error}</span>`
|
|
164
|
+
: enabled
|
|
165
|
+
? "Not advertising yet — restart the app to start."
|
|
166
|
+
: "Advertising is disabled (BONJOUR_ENABLED=off)."}
|
|
167
|
+
</p>`}
|
|
168
|
+
|
|
169
|
+
<div style=${{ borderTop: "1px solid var(--border)", margin: "4px 0 14px" }}></div>
|
|
170
|
+
|
|
171
|
+
<div className="form-group" style=${{ opacity: enabled ? 1 : 0.55 }}>
|
|
172
|
+
<label className="form-label">Service name</label>
|
|
173
|
+
<input className="form-input" value=${name} placeholder="My App (auto: app + hostname)" onInput=${(e) => setName(e.target.value)} />
|
|
174
|
+
</div>
|
|
175
|
+
<div className="form-group">
|
|
176
|
+
<label className="form-label">Service type</label>
|
|
177
|
+
<input className="form-input" value=${type} placeholder="http" onInput=${(e) => setType(e.target.value)} />
|
|
178
|
+
</div>
|
|
179
|
+
<button className="btn btn-primary" onClick=${handleSave} disabled=${saving}>
|
|
180
|
+
${saving ? html`<span className="spinner" />` : null}
|
|
181
|
+
${saving ? "Saving…" : "Save settings"}
|
|
182
|
+
</button>
|
|
183
|
+
<p className="text-sm text-muted" style=${{ marginTop: "8px" }}>
|
|
184
|
+
Changes apply immediately — no restart needed.
|
|
185
|
+
</p>
|
|
186
|
+
</div>`}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<!-- Browse the network -->
|
|
190
|
+
<div className="card">
|
|
191
|
+
<div className="card-title mb-1">Network</div>
|
|
192
|
+
<div className="card-subtitle mb-3">Discover other Bonjour services on this LAN</div>
|
|
193
|
+
<div className="flex items-center gap-2 mb-3" style=${{ display: "flex", gap: "8px" }}>
|
|
194
|
+
<input className="form-input" style=${{ maxWidth: "160px" }} value=${browseType}
|
|
195
|
+
placeholder="http" onInput=${(e) => setBrowseType(e.target.value)}
|
|
196
|
+
onKeyDown=${(e) => e.key === "Enter" && handleBrowse()} />
|
|
197
|
+
<button className="btn btn-primary" onClick=${handleBrowse} disabled=${browsing}>
|
|
198
|
+
${browsing ? html`<span className="spinner" />` : html`<span className="sidebar-icon"><${LuWifi} size=${14} /></span>`}
|
|
199
|
+
${browsing ? "Scanning…" : "Browse"}
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
${services === null
|
|
204
|
+
? html`<div className="empty-state" style=${{ padding: "28px" }}>
|
|
205
|
+
<div className="empty-icon"><${LuWifi} size=${30} /></div>
|
|
206
|
+
<p>Browse for <code>_${browseType || "http"}._tcp</code> services on your network.</p>
|
|
207
|
+
</div>`
|
|
208
|
+
: services.length === 0
|
|
209
|
+
? html`<div className="empty-state" style=${{ padding: "28px" }}>
|
|
210
|
+
<p>No services found. Discovery only works on the same local network, and firewalls blocking UDP 5353 will silently hide services.</p>
|
|
211
|
+
</div>`
|
|
212
|
+
: html`<div className="routes-list">
|
|
213
|
+
${services.map((s) => html`<div key=${`${s.name}|${s.host}|${s.port}`} className="route-item">
|
|
214
|
+
<div style=${{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}>
|
|
215
|
+
<span className="ri-name">${s.name}</span>
|
|
216
|
+
<span className="ri-file" style=${{ fontFamily: "monospace" }}>${s.host}:${s.port}${s.addresses && s.addresses.length ? ` · ${s.addresses.join(", ")}` : ""}</span>
|
|
217
|
+
${s.txt && Object.keys(s.txt).length
|
|
218
|
+
? html`<span className="ri-file" style=${{ opacity: 0.7 }}>${Object.entries(s.txt).map(([k, v]) => `${k}=${v}`).join(" ")}</span>`
|
|
219
|
+
: null}
|
|
220
|
+
</div>
|
|
221
|
+
<a className="btn btn-ghost btn-sm" href=${`http://${s.host}:${s.port}`} target="_blank" rel="noreferrer">Open</a>
|
|
222
|
+
</div>`)}
|
|
223
|
+
</div>`}
|
|
224
|
+
|
|
225
|
+
<p className="text-sm text-muted" style=${{ marginTop: "12px" }}>
|
|
226
|
+
mDNS is local-network only — it can't bridge Wi-Fi and cellular, and guest networks with client isolation block it.
|
|
227
|
+
</p>
|
|
228
|
+
</div>
|
|
229
|
+
</div>`}
|
|
230
|
+
</div>`;
|
|
231
|
+
}
|
|
@@ -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
|
+
|
|
@@ -1318,6 +1318,94 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1318
1318
|
}
|
|
1319
1319
|
});
|
|
1320
1320
|
|
|
1321
|
+
// ---------------------------------------------------------------------------
|
|
1322
|
+
// Bonjour / mDNS discovery — advertise this app + browse the LAN
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
1324
|
+
// The advertising itself lives in src/discovery.js and runs inside THIS process
|
|
1325
|
+
// (started by index.js). We load that exact module by absolute path so the
|
|
1326
|
+
// dashboard reads the very same live state — not a second, disconnected copy.
|
|
1327
|
+
function loadDiscovery() {
|
|
1328
|
+
const url = pathToFileURL(path.join(process.cwd(), 'src', 'discovery.js')).href;
|
|
1329
|
+
return import(url);
|
|
1330
|
+
}
|
|
1331
|
+
const safeServiceType = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 20);
|
|
1332
|
+
|
|
1333
|
+
// Current advertisement state + whether mDNS is available in this project.
|
|
1334
|
+
router.get('/api/discovery', async (req, res) => {
|
|
1335
|
+
try {
|
|
1336
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1337
|
+
const enabled = getEnvValue(envPath, 'BONJOUR_ENABLED') !== 'off';
|
|
1338
|
+
const name = getEnvValue(envPath, 'BONJOUR_NAME');
|
|
1339
|
+
const type = getEnvValue(envPath, 'BONJOUR_TYPE') || 'http';
|
|
1340
|
+
const mod = await loadDiscovery().catch(() => null);
|
|
1341
|
+
const self = mod && mod.getSelfState ? mod.getSelfState() : null;
|
|
1342
|
+
res.json({ available: !!mod, enabled, config: { name, type }, self });
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
res.status(500).json({ error: err.message });
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// Browse the local network for services of a given type.
|
|
1349
|
+
router.get('/api/discovery/browse', async (req, res) => {
|
|
1350
|
+
try {
|
|
1351
|
+
const mod = await loadDiscovery().catch(() => null);
|
|
1352
|
+
if (!mod || !mod.browse) return res.json({ available: false, services: [] });
|
|
1353
|
+
const type = safeServiceType(req.query.type) || 'http';
|
|
1354
|
+
const result = await mod.browse({ type, timeoutMs: 2500 });
|
|
1355
|
+
res.json(result);
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
res.status(500).json({ error: err.message });
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// Persist discovery settings to .env AND apply them live. Because the discovery
|
|
1362
|
+
// module runs in this very process, we can re-advertise immediately instead of
|
|
1363
|
+
// waiting for a restart: mutate the live process.env (so isDiscoveryEnabled and
|
|
1364
|
+
// the auto-name reflect the change), then re-publish / unpublish in place.
|
|
1365
|
+
router.post('/api/discovery/config', async (req, res) => {
|
|
1366
|
+
try {
|
|
1367
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
1368
|
+
const b = req.body || {};
|
|
1369
|
+
if (typeof b.enabled === 'boolean') {
|
|
1370
|
+
const v = b.enabled ? 'on' : 'off';
|
|
1371
|
+
updateEnvFile(envPath, 'BONJOUR_ENABLED', v);
|
|
1372
|
+
process.env.BONJOUR_ENABLED = v;
|
|
1373
|
+
}
|
|
1374
|
+
if (typeof b.name === 'string') {
|
|
1375
|
+
const clean = b.name.replace(/[\r\n]/g, '').trim().slice(0, 63);
|
|
1376
|
+
updateEnvFile(envPath, 'BONJOUR_NAME', clean);
|
|
1377
|
+
process.env.BONJOUR_NAME = clean;
|
|
1378
|
+
}
|
|
1379
|
+
if (typeof b.type === 'string' && b.type.trim()) {
|
|
1380
|
+
const t = safeServiceType(b.type) || 'http';
|
|
1381
|
+
updateEnvFile(envPath, 'BONJOUR_TYPE', t);
|
|
1382
|
+
process.env.BONJOUR_TYPE = t;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Apply live in the running process.
|
|
1386
|
+
let self = null;
|
|
1387
|
+
const mod = await loadDiscovery().catch(() => null);
|
|
1388
|
+
if (mod) {
|
|
1389
|
+
if (process.env.BONJOUR_ENABLED === 'off') {
|
|
1390
|
+
if (mod.stopAdvertising) await mod.stopAdvertising();
|
|
1391
|
+
} else if (mod.startAdvertising) {
|
|
1392
|
+
const st = mod.getSelfState ? mod.getSelfState() : {};
|
|
1393
|
+
const port = st.port || Number(process.env.PORT) || (req.socket && req.socket.localPort);
|
|
1394
|
+
await mod.startAdvertising({
|
|
1395
|
+
port,
|
|
1396
|
+
name: process.env.BONJOUR_NAME || undefined,
|
|
1397
|
+
type: process.env.BONJOUR_TYPE || undefined,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
self = mod.getSelfState ? mod.getSelfState() : null;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
res.json({ message: 'Discovery settings applied.', self });
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
res.status(500).json({ error: err.message });
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1321
1409
|
// ---------------------------------------------------------------------------
|
|
1322
1410
|
// Email (nodemailer) — provider presets, SMTP config in .env, install + test
|
|
1323
1411
|
// ---------------------------------------------------------------------------
|
package/skeleton/.env.example
CHANGED
|
@@ -15,6 +15,16 @@ NODE_ENV=development
|
|
|
15
15
|
RATE_LIMIT_WINDOW_MS=900000
|
|
16
16
|
RATE_LIMIT_MAX=500
|
|
17
17
|
|
|
18
|
+
# ── Bonjour / mDNS service discovery ──
|
|
19
|
+
# The app advertises itself on the local network so other devices can find it by
|
|
20
|
+
# name (http://<host>.local:<port>) without hard-coded IPs. On by default.
|
|
21
|
+
# BONJOUR_ENABLED: "off" disables advertising entirely.
|
|
22
|
+
# BONJOUR_NAME: custom service name shown to other devices (default: app + host).
|
|
23
|
+
# BONJOUR_TYPE: service type without the leading underscore (default: "http").
|
|
24
|
+
# BONJOUR_ENABLED=off
|
|
25
|
+
# BONJOUR_NAME=My App
|
|
26
|
+
# BONJOUR_TYPE=http
|
|
27
|
+
|
|
18
28
|
# ── Dev dashboard (/_dev) — a REMOTE-CODE-EXECUTION surface; keep it local-only ──
|
|
19
29
|
# _4BNODE_DASHBOARD: "on" force-enable (even in production — avoid); "off" disable.
|
|
20
30
|
# Default: enabled only when NODE_ENV is not "production".
|