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
package/skeleton/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import helmet from "helmet";
|
|
|
8
8
|
import rateLimit from "express-rate-limit";
|
|
9
9
|
import { createInterface } from "readline";
|
|
10
10
|
import { notFound, errorHandler } from "./src/middleware/errorHandler.js";
|
|
11
|
+
import { startAdvertising, destroyDiscovery } from "./src/discovery.js";
|
|
11
12
|
|
|
12
13
|
// ── Environment ─────────────────────────────────────
|
|
13
14
|
dotenv.config();
|
|
@@ -139,6 +140,9 @@ async function startServer(tryPort) {
|
|
|
139
140
|
updateEnvPort(tryPort);
|
|
140
141
|
console.log(`Updated .env with PORT=${tryPort}`);
|
|
141
142
|
}
|
|
143
|
+
// Advertise on the local network via Bonjour/mDNS — only now that the server
|
|
144
|
+
// is actually listening on `tryPort`. Re-runs cleanly if the port changed.
|
|
145
|
+
startAdvertising({ port: tryPort }).catch(() => {});
|
|
142
146
|
});
|
|
143
147
|
listener.on("error", async (err) => {
|
|
144
148
|
if (err.code === "EADDRINUSE") {
|
|
@@ -156,4 +160,32 @@ async function startServer(tryPort) {
|
|
|
156
160
|
});
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
// ── Graceful shutdown ───────────────────────────────────────────────────────
|
|
164
|
+
// On a clean exit, unpublish the Bonjour service so TTL=0 "goodbye" packets are
|
|
165
|
+
// sent and clients drop the stale record immediately. Only an uncatchable
|
|
166
|
+
// SIGKILL now bypasses this.
|
|
167
|
+
let shuttingDown = false;
|
|
168
|
+
async function gracefulShutdown(signal) {
|
|
169
|
+
if (shuttingDown) return;
|
|
170
|
+
shuttingDown = true;
|
|
171
|
+
console.log(`\nReceived ${signal}, shutting down...`);
|
|
172
|
+
try {
|
|
173
|
+
await destroyDiscovery();
|
|
174
|
+
} catch {}
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
178
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
179
|
+
|
|
180
|
+
// nodemon restarts the app by sending SIGUSR2. Unpublish first (so clients get
|
|
181
|
+
// the goodbye packet and no stale record lingers across dev restarts), then
|
|
182
|
+
// re-raise the signal so nodemon performs the actual restart. once() prevents a
|
|
183
|
+
// loop by removing our handler before we re-raise.
|
|
184
|
+
process.once("SIGUSR2", async () => {
|
|
185
|
+
try {
|
|
186
|
+
await destroyDiscovery();
|
|
187
|
+
} catch {}
|
|
188
|
+
process.kill(process.pid, "SIGUSR2");
|
|
189
|
+
});
|
|
190
|
+
|
|
159
191
|
startServer(Number(port));
|
package/skeleton/package.json
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Bonjour / mDNS service discovery
|
|
2
|
+
// Zero-config LAN networking: this app advertises itself so other devices can
|
|
3
|
+
// find it by name (host.local) without hard-coded IPs, and can browse for other
|
|
4
|
+
// services on the same network.
|
|
5
|
+
//
|
|
6
|
+
// This module is written to be defensive and robust. The lessons baked in here
|
|
7
|
+
// are exactly the ones that make mDNS silently fail in the wild:
|
|
8
|
+
//
|
|
9
|
+
// - bonjour-service is imported LAZILY. A project that has not installed it (or
|
|
10
|
+
// runs somewhere the native mDNS socket cannot bind) still boots fine;
|
|
11
|
+
// advertising just becomes a no-op in an "unsupported" state.
|
|
12
|
+
// - The SRV host is ALWAYS explicitly .local-suffixed. os.hostname() alone can
|
|
13
|
+
// return a bare name (e.g. "MSI" on Windows), which routes address lookups to
|
|
14
|
+
// unicast DNS instead of multicast DNS and never resolves.
|
|
15
|
+
// - Only STABLE metadata goes in the TXT record (platform, app name). Putting
|
|
16
|
+
// per-restart / per-interface data like an IP in TXT produces conflicting
|
|
17
|
+
// duplicate records; some clients (notably iOS NWBrowser) then silently never
|
|
18
|
+
// resolve the service, with no error and no timeout.
|
|
19
|
+
// - A single service instance is kept at a time; re-advertising (e.g. after a
|
|
20
|
+
// port change) stops the old one first so the network never sees duplicates.
|
|
21
|
+
// - Clean shutdown sends TTL=0 "goodbye" packets so stale records do not linger
|
|
22
|
+
// in client caches for the record's full TTL (can be over an hour).
|
|
23
|
+
//
|
|
24
|
+
// Configure via .env: BONJOUR_ENABLED=off | BONJOUR_NAME=... | BONJOUR_TYPE=http
|
|
25
|
+
|
|
26
|
+
import os from "os";
|
|
27
|
+
|
|
28
|
+
let bonjour = null; // shared mDNS controller (created lazily, one per process)
|
|
29
|
+
let published = null; // handle to the currently-advertised service, if any
|
|
30
|
+
let BonjourCtor = null; // cached constructor after the first successful import
|
|
31
|
+
|
|
32
|
+
let selfState = {
|
|
33
|
+
status: "idle", // idle | advertising | error | unsupported
|
|
34
|
+
name: null,
|
|
35
|
+
type: null,
|
|
36
|
+
port: null,
|
|
37
|
+
host: null,
|
|
38
|
+
txt: null,
|
|
39
|
+
error: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Import bonjour-service on demand. Returns the constructor, or null if the
|
|
43
|
+
// package is not installed / cannot load. Handles both the named (Bonjour) and
|
|
44
|
+
// default export shapes across versions.
|
|
45
|
+
async function loadBonjour() {
|
|
46
|
+
if (BonjourCtor) return BonjourCtor;
|
|
47
|
+
try {
|
|
48
|
+
const mod = await import("bonjour-service");
|
|
49
|
+
BonjourCtor =
|
|
50
|
+
mod.Bonjour ||
|
|
51
|
+
mod.default ||
|
|
52
|
+
(mod.default && mod.default.Bonjour) ||
|
|
53
|
+
null;
|
|
54
|
+
return BonjourCtor;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Always .local-suffixed, single-label host so address lookups use mDNS.
|
|
61
|
+
function localHostname() {
|
|
62
|
+
const base = (os.hostname() || "device").split(".")[0].trim() || "device";
|
|
63
|
+
return base + ".local";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build a network-unique default service name. Two apps scaffolded from the same
|
|
67
|
+
// skeleton on different machines would otherwise collide on the same name.
|
|
68
|
+
function defaultServiceName() {
|
|
69
|
+
// An explicit BONJOUR_NAME is used verbatim — the user owns uniqueness.
|
|
70
|
+
if (process.env.BONJOUR_NAME) return process.env.BONJOUR_NAME;
|
|
71
|
+
// Auto name: project name + hostname, so two machines don't collide.
|
|
72
|
+
const base = process.env.APP_NAME || process.env.npm_package_name || "4bnode-app";
|
|
73
|
+
const host = (os.hostname() || "").split(".")[0].replace(/[^a-zA-Z0-9-]/g, "");
|
|
74
|
+
return host ? base + " (" + host + ")" : base;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// On by default; opt out with BONJOUR_ENABLED=off.
|
|
78
|
+
export function isDiscoveryEnabled() {
|
|
79
|
+
return process.env.BONJOUR_ENABLED !== "off";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Snapshot of what this process is currently advertising (read by the dashboard).
|
|
83
|
+
export function getSelfState() {
|
|
84
|
+
return { ...selfState, enabled: isDiscoveryEnabled() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Advertise this app on the local network. Safe to call repeatedly; each call
|
|
88
|
+
// replaces any prior advertisement, so a port change re-publishes cleanly. Must
|
|
89
|
+
// be called AFTER the HTTP server is actually listening (so the port is bound).
|
|
90
|
+
export async function startAdvertising({ port, name, type } = {}) {
|
|
91
|
+
if (!isDiscoveryEnabled()) {
|
|
92
|
+
selfState = { ...selfState, status: "idle", error: null };
|
|
93
|
+
return getSelfState();
|
|
94
|
+
}
|
|
95
|
+
if (!port) return getSelfState();
|
|
96
|
+
|
|
97
|
+
const Ctor = await loadBonjour();
|
|
98
|
+
if (!Ctor) {
|
|
99
|
+
selfState = {
|
|
100
|
+
status: "unsupported",
|
|
101
|
+
name: null,
|
|
102
|
+
type: null,
|
|
103
|
+
port: null,
|
|
104
|
+
host: null,
|
|
105
|
+
txt: null,
|
|
106
|
+
error: "bonjour-service not installed",
|
|
107
|
+
};
|
|
108
|
+
console.log(
|
|
109
|
+
"mDNS: bonjour-service not installed - LAN discovery disabled. Run: npm install bonjour-service to enable.",
|
|
110
|
+
);
|
|
111
|
+
return getSelfState();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Replace any previous advertisement so two conflicting records never coexist.
|
|
115
|
+
await stopAdvertising();
|
|
116
|
+
if (!bonjour) bonjour = new Ctor();
|
|
117
|
+
|
|
118
|
+
const serviceName = name || defaultServiceName();
|
|
119
|
+
const serviceType = type || process.env.BONJOUR_TYPE || "http";
|
|
120
|
+
const host = localHostname();
|
|
121
|
+
const txt = {
|
|
122
|
+
platform: os.platform(), // stable across restarts - safe for TXT
|
|
123
|
+
app: process.env.APP_NAME || process.env.npm_package_name || "4bnode-app",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
published = bonjour.publish({ name: serviceName, type: serviceType, port, host, txt });
|
|
128
|
+
selfState = {
|
|
129
|
+
status: "advertising",
|
|
130
|
+
name: serviceName,
|
|
131
|
+
type: serviceType,
|
|
132
|
+
port,
|
|
133
|
+
host,
|
|
134
|
+
txt,
|
|
135
|
+
error: null,
|
|
136
|
+
};
|
|
137
|
+
published.on("up", () => {
|
|
138
|
+
console.log(
|
|
139
|
+
"mDNS: advertising " + JSON.stringify(serviceName) + " at " + host + ":" + port + " (_" + serviceType + "._tcp.local)",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
published.on("error", (err) => {
|
|
143
|
+
const msg = String((err && err.message) || err);
|
|
144
|
+
selfState = { ...selfState, status: "error", error: msg };
|
|
145
|
+
console.warn(
|
|
146
|
+
"mDNS: advertising error - " + msg + ". If discovery is not working, check that UDP port 5353 is not blocked by a firewall.",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
selfState = { ...selfState, status: "error", error: String((err && err.message) || err) };
|
|
151
|
+
}
|
|
152
|
+
return getSelfState();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Stop advertising and send the TTL=0 goodbye packets. Resolves once the mDNS
|
|
156
|
+
// responder has flushed them (or after a short safety timeout, so a stuck
|
|
157
|
+
// responder can never hang process shutdown).
|
|
158
|
+
export function stopAdvertising() {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
if (!bonjour) {
|
|
161
|
+
published = null;
|
|
162
|
+
return resolve();
|
|
163
|
+
}
|
|
164
|
+
let done = false;
|
|
165
|
+
const finish = () => {
|
|
166
|
+
if (done) return;
|
|
167
|
+
done = true;
|
|
168
|
+
published = null;
|
|
169
|
+
if (selfState.status === "advertising") selfState = { ...selfState, status: "idle" };
|
|
170
|
+
resolve();
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
bonjour.unpublishAll(finish); // constructs + sends goodbye packets
|
|
174
|
+
const t = setTimeout(finish, 1500);
|
|
175
|
+
if (t.unref) t.unref();
|
|
176
|
+
} catch {
|
|
177
|
+
finish();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Full teardown for process shutdown: goodbye packets, then destroy the socket.
|
|
183
|
+
export async function destroyDiscovery() {
|
|
184
|
+
await stopAdvertising();
|
|
185
|
+
try {
|
|
186
|
+
if (bonjour) bonjour.destroy();
|
|
187
|
+
} catch {}
|
|
188
|
+
bonjour = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Browse the LAN for services of a given type. Collects results for timeoutMs
|
|
192
|
+
// (de-duplicated, excluding our own advertisement), then stops the browser and
|
|
193
|
+
// resolves. "available" is false when bonjour-service is not installed.
|
|
194
|
+
export async function browse({ type = "http", timeoutMs = 2500 } = {}) {
|
|
195
|
+
const Ctor = await loadBonjour();
|
|
196
|
+
if (!Ctor) return { available: false, services: [] };
|
|
197
|
+
if (!bonjour) bonjour = new Ctor();
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const found = new Map();
|
|
201
|
+
let browser = null;
|
|
202
|
+
let settled = false;
|
|
203
|
+
|
|
204
|
+
const finish = () => {
|
|
205
|
+
if (settled) return;
|
|
206
|
+
settled = true;
|
|
207
|
+
try {
|
|
208
|
+
if (browser && typeof browser.stop === "function") browser.stop();
|
|
209
|
+
} catch {}
|
|
210
|
+
resolve({ available: true, services: Array.from(found.values()) });
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
browser = bonjour.find({ type }, (service) => {
|
|
215
|
+
// Exclude our own advertisement from the peer list.
|
|
216
|
+
if (published && selfState.name && service.name === selfState.name) return;
|
|
217
|
+
const key = service.name + "|" + service.host + "|" + service.port;
|
|
218
|
+
found.set(key, {
|
|
219
|
+
name: service.name,
|
|
220
|
+
host: service.host,
|
|
221
|
+
port: service.port,
|
|
222
|
+
type: service.type,
|
|
223
|
+
protocol: service.protocol,
|
|
224
|
+
addresses: Array.isArray(service.addresses) ? service.addresses : [],
|
|
225
|
+
txt: service.txt || {},
|
|
226
|
+
fqdn: service.fqdn || null,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
settled = true;
|
|
231
|
+
return resolve({ available: true, services: [], error: String((err && err.message) || err) });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const t = setTimeout(finish, Math.max(500, Math.min(10000, Number(timeoutMs) || 2500)));
|
|
235
|
+
if (t.unref) t.unref();
|
|
236
|
+
});
|
|
237
|
+
}
|