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/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));
@@ -18,7 +18,8 @@
18
18
  "cors": "^2.8.6",
19
19
  "helmet": "^8.0.0",
20
20
  "express-rate-limit": "^7.4.0",
21
- "zod": "^3.23.0"
21
+ "zod": "^3.23.0",
22
+ "bonjour-service": "^1.4.3"
22
23
  },
23
24
  "devDependencies": {
24
25
  "nodemon": "^3.1.14"
@@ -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
+ }