@devioarts/capacitor-mdns 0.0.3 → 0.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DevioartsCapacitorMdns.podspec +3 -3
  3. package/Package.swift +1 -1
  4. package/README.md +272 -101
  5. package/android/build.gradle +3 -9
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
  8. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
  9. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
  10. package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
  11. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +496 -175
  12. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +85 -49
  13. package/dist/docs.json +313 -15
  14. package/dist/esm/definitions.d.ts +89 -16
  15. package/dist/esm/definitions.js.map +1 -1
  16. package/dist/esm/index.js +22 -1
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/web.d.ts +8 -1
  19. package/dist/esm/web.js +35 -3
  20. package/dist/esm/web.js.map +1 -1
  21. package/dist/plugin.cjs.js +57 -4
  22. package/dist/plugin.cjs.js.map +1 -1
  23. package/dist/plugin.js +57 -4
  24. package/dist/plugin.js.map +1 -1
  25. package/docs/electron.md +91 -0
  26. package/docs/platform-setup.md +58 -0
  27. package/docs/playground.md +50 -0
  28. package/docs/runtime-behavior.md +80 -0
  29. package/electron/dist/plugin-settings.js +22 -0
  30. package/electron/dist/plugin-settings.js.map +1 -0
  31. package/electron/dist/plugin.cjs.js +774 -0
  32. package/electron/dist/plugin.cjs.js.map +1 -0
  33. package/electron/global.d.ts +2 -0
  34. package/electron/mdns-bridge.cjs +33 -4
  35. package/electron/mdns.ts +628 -107
  36. package/electron/rollup.config.mjs +22 -0
  37. package/electron/src/index.ts +2 -0
  38. package/electron/src/plugin-settings.ts +19 -0
  39. package/electron/tsconfig.json +21 -0
  40. package/electron/types-public/index.d.ts +47 -0
  41. package/ios/Sources/mDNSPlugin/mDNS.swift +481 -124
  42. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +85 -23
  43. package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
  44. package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
  45. package/package.json +55 -20
  46. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -15
package/electron/mdns.ts CHANGED
@@ -1,15 +1,56 @@
1
- import type { Service, Browser } from 'bonjour-service';
2
1
  import Bonjour from 'bonjour-service';
3
- import { ipcMain } from 'electron';
2
+ import { app, BrowserWindow, ipcMain } from 'electron';
3
+ import { isAbsolute, relative, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { EventEmitter } from 'node:events';
6
+ import { isIPv4 } from 'node:net';
4
7
 
5
8
  import type {
6
9
  MdnsBroadcastOptions,
7
10
  MdnsBroadcastResult,
8
11
  MdnsDiscoverOptions,
9
12
  MdnsDiscoverResult,
13
+ MdnsPluginPlatformResult,
10
14
  MdnsService,
11
15
  MdnsStopResult,
12
- } from '../src';
16
+ MdnsWatchOptions,
17
+ MdnsWatchResult,
18
+ MdnsWatchStopResult,
19
+ } from '../src/definitions';
20
+
21
+ type Service = InstanceType<typeof Bonjour.Service>;
22
+ type Browser = InstanceType<typeof Bonjour.Browser>;
23
+
24
+ /** The subset of `bonjour-service` this class uses; injectable so tests can fake the network. */
25
+ export type BonjourLike = Pick<Bonjour, 'publish' | 'find' | 'destroy'>;
26
+
27
+ export interface MdnsOptions {
28
+ /** Max time to wait for a publish to come up (default 5000). */
29
+ publishTimeoutMs?: number;
30
+ /** Max time to wait for a service to stop (default 3000). */
31
+ stopTimeoutMs?: number;
32
+ /** Exact renderer origins allowed in addition to packaged app URLs (e.g. your dev server). */
33
+ trustedOrigins?: string[];
34
+ }
35
+
36
+ const DEFAULT_DISCOVER_TIMEOUT_MS = 3000;
37
+ /** `setTimeout` treats delays above 2^31-1 as 1ms, so clamp. */
38
+ const MAX_TIMER_MS = 2_147_483_647;
39
+
40
+ interface Watch {
41
+ types: string[];
42
+ browsers: Browser[];
43
+ services: Map<string, { service: MdnsService; fqdn: string; expires: number; announced: boolean }>;
44
+ timer?: NodeJS.Timeout;
45
+ onResponse?: (packet: { answers?: DnsRecord[]; additionals?: DnsRecord[] }) => void;
46
+ }
47
+
48
+ interface DnsRecord {
49
+ name: string;
50
+ type: string;
51
+ ttl: number;
52
+ data: unknown;
53
+ }
13
54
 
14
55
  /**
15
56
  * Electron main-process implementation of the mDNS/Bonjour functionality.
@@ -17,13 +58,76 @@ import type {
17
58
  * Uses the `bonjour-service` package to:
18
59
  * - publish (advertise) a single service;
19
60
  * - discover services of a given type and normalize results to the shared schema;
20
- * - wire the API over Electron's ipcMain (see registerIpc/attachOnReady).
61
+ * - wire the API over Electron's ipcMain through init().
21
62
  *
22
63
  * Notes:
23
64
  * - Instance name matching is normalized and prefix-safe, mirroring iOS/Android behavior.
24
65
  * - TXT records are forwarded as strings when present.
25
66
  */
26
- export class mDNS {
67
+ export class mDNS extends EventEmitter {
68
+ private readonly publishTimeoutMs: number;
69
+ private readonly stopTimeoutMs: number;
70
+ private broadcastQueue: Promise<unknown> = Promise.resolve();
71
+ private destroyed = false;
72
+ /** Settle callbacks of in-flight discoveries, so destroy() can end them instead of leaving them waiting. */
73
+ private readonly activeDiscoveries = new Set<(abortMessage?: string) => void>();
74
+ private bonjourInstance?: BonjourLike;
75
+ private transport?: EventEmitter & { query?: (name: string, type: string) => void };
76
+ private readonly trustedOrigins: Set<string>;
77
+ private pendingPublishError?: (error: Error) => void;
78
+ private watch?: Watch;
79
+ private networkError?: string;
80
+ private readonly stoppedBrowsers = new Set<Browser>();
81
+ private readonly deactivateBrowser = new Map<Browser, () => void>();
82
+
83
+ constructor(bonjour?: BonjourLike, options: MdnsOptions = {}) {
84
+ super();
85
+ this.trustedOrigins = new Set(options.trustedOrigins ?? []);
86
+ if (bonjour) this.attachBonjour(bonjour);
87
+ this.publishTimeoutMs = options.publishTimeoutMs ?? 5000;
88
+ this.stopTimeoutMs = options.stopTimeoutMs ?? 3000;
89
+ }
90
+
91
+ private attachBonjour(bonjour: BonjourLike): BonjourLike {
92
+ this.bonjourInstance = bonjour;
93
+ // shortcut: Bonjour 1.4 needs its internal transport for errors/TTL; recheck these tests on dependency upgrades.
94
+ this.transport = (bonjour as unknown as { server?: { mdns: EventEmitter } }).server?.mdns;
95
+ this.transport?.on('error', this.onNetworkError);
96
+ return bonjour;
97
+ }
98
+ private get bonjour(): BonjourLike {
99
+ return (
100
+ this.bonjourInstance ??
101
+ this.attachBonjour(new Bonjour({}, (err: Error) => console.warn('[mDNS] query response failed:', err)))
102
+ );
103
+ }
104
+ private trustedUrl(raw: string): boolean {
105
+ try {
106
+ const url = new URL(raw);
107
+ // Capacitor Electron's default dev renderer; packaged apps require an explicit origin.
108
+ if (app?.isPackaged === false && `${url.protocol}//${url.host}` === 'http://localhost:5173') return true;
109
+ if (
110
+ url.protocol === 'capacitor-electron:' &&
111
+ url.hostname === 'localhost' &&
112
+ !url.username &&
113
+ !url.password &&
114
+ !url.port
115
+ )
116
+ return true;
117
+ if (url.protocol === 'file:') {
118
+ const path = relative(app.getAppPath(), fileURLToPath(url));
119
+ return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
120
+ }
121
+ return this.trustedOrigins.has(`${url.protocol}//${url.host}`);
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+ private trustedSender(event: Electron.IpcMainInvokeEvent): boolean {
127
+ return (
128
+ !!event.senderFrame && event.senderFrame === event.sender.mainFrame && this.trustedUrl(event.senderFrame.url)
129
+ );
130
+ }
27
131
  //<editor-fold desc="Init/Destroy">
28
132
  private ipcRegistered = false;
29
133
  private registerIpc(): void {
@@ -32,12 +136,24 @@ export class mDNS {
32
136
  ipcMain.removeHandler('mdns:startBroadcast');
33
137
  ipcMain.removeHandler('mdns:stopBroadcast');
34
138
  ipcMain.removeHandler('mdns:discover');
139
+ ipcMain.removeHandler('mdns:getPluginPlatform');
140
+ ipcMain.removeHandler('mdns:watchServices');
141
+ ipcMain.removeHandler('mdns:stopWatching');
35
142
  } catch {
36
143
  /* ignore */
37
144
  }
38
- ipcMain.handle('mdns:startBroadcast', (_evt, o: MdnsBroadcastOptions) => this.startBroadcast(o));
39
- ipcMain.handle('mdns:stopBroadcast', () => this.stopBroadcast());
40
- ipcMain.handle('mdns:discover', (_evt, o: MdnsDiscoverOptions) => this.discover(o));
145
+ const handle = (channel: string, action: (options: any) => unknown) => {
146
+ ipcMain.handle(channel, (event, options) => {
147
+ if (!this.trustedSender(event)) throw new Error('Untrusted mDNS IPC sender');
148
+ return action(options);
149
+ });
150
+ };
151
+ handle('mdns:startBroadcast', (o) => this.startBroadcast(o));
152
+ handle('mdns:stopBroadcast', () => this.stopBroadcast());
153
+ handle('mdns:discover', (o) => this.discover(o));
154
+ handle('mdns:getPluginPlatform', () => this.getPluginPlatform());
155
+ handle('mdns:watchServices', (o) => this.watchServices(o));
156
+ handle('mdns:stopWatching', () => this.stopWatching());
41
157
  this.ipcRegistered = true;
42
158
  }
43
159
 
@@ -47,38 +163,62 @@ export class mDNS {
47
163
  ipcMain.removeHandler('mdns:startBroadcast');
48
164
  ipcMain.removeHandler('mdns:stopBroadcast');
49
165
  ipcMain.removeHandler('mdns:discover');
166
+ ipcMain.removeHandler('mdns:getPluginPlatform');
167
+ ipcMain.removeHandler('mdns:watchServices');
168
+ ipcMain.removeHandler('mdns:stopWatching');
50
169
  } catch {
51
170
  /* ignore */
52
171
  }
53
172
  this.ipcRegistered = false;
54
173
  }
55
- /** Manual IPC registration (if you set autoRegisterIpc: false). Idempotent. */
174
+ /** Register manual IPC handlers. Idempotent. */
56
175
  init(): void {
57
176
  this.registerIpc();
58
177
  }
59
178
 
60
179
  async destroy(): Promise<void> {
180
+ this.destroyed = true;
181
+ this.pendingPublishError?.(new Error('mDNS instance destroyed during publish'));
182
+ await this.stopWatching();
61
183
  this.unregisterIpc();
62
- await this.stopBroadcast(); // no-op safe
63
- try {
64
- this.bonjour.destroy();
65
- } catch (err) {
66
- console.warn('[mDNS] bonjour.destroy error:', err);
67
- }
184
+ for (const abort of [...this.activeDiscoveries]) abort('mDNS instance destroyed during discovery');
185
+ await this.withBroadcastLock(async () => {
186
+ await this.stopBroadcastUnlocked();
187
+ try {
188
+ this.bonjourInstance?.destroy();
189
+ this.transport?.removeListener('error', this.onNetworkError);
190
+ this.advertiser = undefined;
191
+ } catch (err) {
192
+ console.warn('[mDNS] bonjour.destroy error:', err);
193
+ }
194
+ });
68
195
  }
69
196
 
70
197
  //</editor-fold>
71
198
 
72
- private bonjour = new Bonjour();
73
199
  private advertiser?: Service;
74
200
 
75
201
  // ----------------------------- utils -----------------------------
76
202
  private toErr(err: unknown): string {
77
203
  return err instanceof Error ? err.message : String(err);
78
204
  }
205
+ private validatePort(port: unknown): string | null {
206
+ return Number.isInteger(port) && typeof port === 'number' && port > 0 && port <= 65535
207
+ ? null
208
+ : 'Missing/invalid port';
209
+ }
210
+ private isRecord(value: unknown): value is Record<string, unknown> {
211
+ return !!value && typeof value === 'object' && !Array.isArray(value);
212
+ }
213
+ private withBroadcastLock<T>(work: () => Promise<T>): Promise<T> {
214
+ // Publish and stop share one Bonjour service; serializing them prevents orphaned advertisers.
215
+ const run = this.broadcastQueue.catch(() => undefined).then(work);
216
+ this.broadcastQueue = run.catch(() => undefined);
217
+ return run;
218
+ }
79
219
 
80
220
  private normalize(name: string): string {
81
- return name.replace(/ \(\d+\)$/, '');
221
+ return name.replace(/ \(\d+\)$/, '').replace(/\./g, '-');
82
222
  }
83
223
  private matchesTarget(candidate: string, target?: string | null): boolean {
84
224
  if (!target) return true;
@@ -86,33 +226,73 @@ export class mDNS {
86
226
  t = this.normalize(target);
87
227
  return c === t || c.startsWith(t);
88
228
  }
89
- private parseType(typeWithDot?: string): { type: string; protocol: 'tcp' | 'udp' } {
90
- const s = (typeWithDot ?? '_http._tcp.').replace(/\.$/, '');
91
- const m = /^_([^.]+)\._(tcp|udp)$/.exec(s);
92
- if (!m) return { type: 'http', protocol: 'tcp' };
93
- return { type: m[1], protocol: m[2] as 'tcp' | 'udp' };
229
+ /**
230
+ * Parse a `_name._tcp.` style type. Absent/blank input means the default `_http._tcp.`;
231
+ * anything malformed is rejected (null) rather than silently advertised as something else.
232
+ */
233
+ private parseType(typeWithDot?: unknown): { type: string; protocol: 'tcp' | 'udp' } | null {
234
+ if (typeWithDot != null && typeof typeWithDot !== 'string') return null;
235
+ const raw = typeof typeWithDot === 'string' && typeWithDot.trim() ? typeWithDot.trim() : '_http._tcp.';
236
+ const m = /^_([a-z0-9](?:[a-z0-9-]{0,13}[a-z0-9])?)\._(tcp|udp)\.?$/i.exec(raw);
237
+ return m && /[a-z]/i.test(m[1]) && !m[1].includes('--')
238
+ ? { type: m[1].toLowerCase(), protocol: m[2].toLowerCase() as 'tcp' | 'udp' }
239
+ : null;
240
+ }
241
+ private validateName(name: unknown): string | null {
242
+ return name != null &&
243
+ (typeof name !== 'string' || Buffer.byteLength(name.trim(), 'utf8') > 63 || /[\x00-\x1f\x7f]/.test(name))
244
+ ? 'Invalid instance name (maximum 63 UTF-8 bytes, no control characters)'
245
+ : null;
246
+ }
247
+ private validateTxt(txt: unknown): string | null {
248
+ if (txt == null) return null;
249
+ if (!this.isRecord(txt)) return 'TXT must be an object of strings';
250
+ const keys = new Set<string>();
251
+ let totalBytes = 0;
252
+ for (const [key, value] of Object.entries(txt)) {
253
+ if (!/^[\x20-\x7e]+$/.test(key) || key.includes('=') || keys.has(key.toLowerCase()))
254
+ return 'Invalid or duplicate TXT key';
255
+ if (typeof value !== 'string' || Buffer.byteLength(`${key}=${value}`, 'utf8') > 255)
256
+ return 'TXT entries must be strings of at most 255 UTF-8 bytes including key=';
257
+ totalBytes += Buffer.byteLength(`${key}=${value}`, 'utf8') + 1;
258
+ if (totalBytes > 1300) return 'TXT record must fit in 1300 bytes including length prefixes';
259
+ keys.add(key.toLowerCase());
260
+ }
261
+ return null;
262
+ }
263
+
264
+ private invalidTypeMessage(typeWithDot: unknown): string {
265
+ return `Invalid service type: ${String(typeWithDot)} (expected e.g. "_http._tcp.")`;
266
+ }
267
+ private normalizeTimeout(value: unknown): number {
268
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_DISCOVER_TIMEOUT_MS;
269
+ return Math.min(Math.max(0, Math.trunc(value)), MAX_TIMER_MS);
94
270
  }
95
271
  private toFullType(type: string, protocol: 'tcp' | 'udp'): string {
96
272
  return `_${type}._${protocol}.`;
97
273
  }
98
274
 
99
- private hasCbStop(x: unknown): x is { stop: (cb?: () => void) => void } {
100
- return !!x && typeof (x as any).stop === 'function';
101
- }
102
275
  private async safeStopService(svc: Service | undefined): Promise<void> {
103
- if (!svc || !this.hasCbStop(svc)) return;
104
- try {
105
- await new Promise<void>((res) => {
106
- try {
107
- svc.stop(() => res());
108
- } catch (e) {
109
- console.warn('[mDNS] service.stop threw:', e);
110
- res();
111
- }
112
- });
113
- } catch (err) {
114
- console.warn('[mDNS] service.stop error:', err);
115
- }
276
+ if (!svc) return;
277
+ await new Promise<void>((res, reject) => {
278
+ let settled = false;
279
+ const done = (error?: unknown) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ if (timeout) clearTimeout(timeout);
283
+ if (error) reject(error);
284
+ else res();
285
+ };
286
+ const timeout = setTimeout(
287
+ () => done(new Error(`Timed out waiting for service stop after ${this.stopTimeoutMs}ms`)),
288
+ this.stopTimeoutMs,
289
+ );
290
+ try {
291
+ svc.stop(done);
292
+ } catch (e) {
293
+ done(e);
294
+ }
295
+ });
116
296
  }
117
297
  private safeStopBrowser(b: Browser | null | undefined): void {
118
298
  if (!b) return;
@@ -124,61 +304,144 @@ export class mDNS {
124
304
  }
125
305
 
126
306
  // ----------------------------- API -----------------------------
307
+ /**
308
+ * Return the Electron main-process implementation marker.
309
+ */
310
+ async getPluginPlatform(): Promise<MdnsPluginPlatformResult> {
311
+ return { platform: 'electron' };
312
+ }
313
+
127
314
  /**
128
315
  * Publish (advertise) a single Bonjour/mDNS service via bonjour-service.
129
316
  * @param options See MdnsBroadcastOptions for type/name/port/txt.
130
317
  * @returns Result indicating whether publishing is active and the final name.
131
318
  */
132
319
  async startBroadcast(options: MdnsBroadcastOptions): Promise<MdnsBroadcastResult> {
133
- const { type, protocol } = this.parseType(options.type);
134
- return new Promise<MdnsBroadcastResult>((resolve) => {
135
- let settled = false;
136
- const safeResolve = (r: MdnsBroadcastResult) => {
137
- if (!settled) {
138
- settled = true;
139
- resolve(r);
140
- }
141
- };
142
-
143
- try {
144
- const svc = this.bonjour.publish({
145
- name: options.name || 'DevIOArtsMDNS',
146
- type,
147
- protocol,
148
- port: options.port,
149
- txt: options.txt,
150
- });
320
+ return this.withBroadcastLock(async () => {
321
+ const safeOptions = (options ?? {}) as Partial<MdnsBroadcastOptions>;
322
+ const portError =
323
+ this.validatePort(safeOptions.port) ?? this.validateName(safeOptions.name) ?? this.validateTxt(safeOptions.txt);
324
+ if (portError) return { publishing: !!this.advertiser, name: '', error: true, errorMessage: portError };
325
+ if (this.destroyed)
326
+ return { publishing: false, name: '', error: true, errorMessage: 'mDNS instance is destroyed' };
327
+ if (this.networkError)
328
+ return { publishing: !!this.advertiser, name: '', error: true, errorMessage: this.networkError };
151
329
 
152
- const onUp = () => {
153
- try {
154
- svc.removeListener('up', onUp);
155
- svc.removeListener('error', onError);
156
- } catch {
157
- /* ignore */
158
- }
159
- safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
330
+ const parsed = this.parseType(safeOptions.type);
331
+ if (!parsed) {
332
+ return {
333
+ publishing: !!this.advertiser,
334
+ name: '',
335
+ error: true,
336
+ errorMessage: this.invalidTypeMessage(safeOptions.type),
160
337
  };
161
- const onError = (err: unknown) => {
162
- try {
163
- svc.removeListener('up', onUp);
164
- svc.removeListener('error', onError);
165
- } catch {
166
- /* ignore */
167
- }
168
- try {
169
- if (this.hasCbStop(svc)) svc.stop();
170
- } catch (e) {
171
- console.warn('[mDNS] publish stop on error:', e);
338
+ }
339
+ const { type, protocol } = parsed;
340
+
341
+ const stopped = await this.stopBroadcastUnlocked();
342
+ if (stopped.error) return { ...stopped, name: '' };
343
+
344
+ return new Promise<MdnsBroadcastResult>((resolve) => {
345
+ let settled = false;
346
+ let failing = false;
347
+ let timeout: NodeJS.Timeout | null = null;
348
+ const safeResolve = (r: MdnsBroadcastResult) => {
349
+ if (!settled) {
350
+ settled = true;
351
+ if (timeout) {
352
+ clearTimeout(timeout);
353
+ timeout = null;
354
+ }
355
+ this.pendingPublishError = undefined;
356
+ resolve(r);
172
357
  }
173
- safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(err) });
174
358
  };
175
359
 
176
- svc.once('up', onUp);
177
- svc.once('error', onError);
178
- this.advertiser = svc;
179
- } catch (e) {
180
- safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(e) });
181
- }
360
+ try {
361
+ const svc = this.bonjour.publish({
362
+ name:
363
+ typeof safeOptions.name === 'string' && safeOptions.name.trim()
364
+ ? safeOptions.name.trim()
365
+ : 'DevIOArtsMDNS',
366
+ type,
367
+ protocol,
368
+ port: safeOptions.port as number,
369
+ txt: this.isRecord(safeOptions.txt) ? this.normalizeTxt(safeOptions.txt) : undefined,
370
+ });
371
+
372
+ const cleanupListeners = () => {
373
+ try {
374
+ svc.removeListener('up', onUp);
375
+ svc.removeListener('error', onError);
376
+ } catch {
377
+ /* ignore */
378
+ }
379
+ };
380
+ const onUp = () => {
381
+ if (failing || settled) return;
382
+ cleanupListeners();
383
+ safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
384
+ };
385
+ const onError = async (err: unknown) => {
386
+ if (failing || settled) return;
387
+ failing = true;
388
+ if (timeout) clearTimeout(timeout);
389
+ timeout = null;
390
+ cleanupListeners();
391
+ const stopped = await this.stopBroadcastUnlocked();
392
+ safeResolve({
393
+ publishing: stopped.publishing,
394
+ name: '',
395
+ error: true,
396
+ errorMessage: [this.toErr(err), stopped.errorMessage].filter(Boolean).join('; '),
397
+ });
398
+ };
399
+ const onTimeout = () => {
400
+ void onError(
401
+ new Error(
402
+ svc.activated === false
403
+ ? 'Service name is already in use on the network'
404
+ : `Timed out waiting for service publish after ${this.publishTimeoutMs}ms`,
405
+ ),
406
+ );
407
+ };
408
+
409
+ // shortcut: Bonjour 1.4 sends goodbye for conflicting probes; remove this hook when its probe teardown is fixed.
410
+ const serviceRecords = svc.records;
411
+ let announcementStarted = svc.published === true;
412
+ svc.records = () => {
413
+ if (svc.destroyed) return [];
414
+ // Bonjour sets published only after the send callback, but records are already registered before it.
415
+ announcementStarted = true;
416
+ return serviceRecords.call(svc);
417
+ };
418
+ const stop = svc.stop.bind(svc);
419
+ svc.stop = (callback?: (error?: unknown) => void) => {
420
+ const announced = announcementStarted || svc.published === true;
421
+ const conflict = !callback && svc.activated && !announced;
422
+ const records = svc.records;
423
+ if (!announced) svc.records = () => [];
424
+ try {
425
+ stop(callback);
426
+ svc.destroyed = true;
427
+ } finally {
428
+ svc.records = records;
429
+ }
430
+ if (conflict) void onError(new Error('Service name is already in use on the network'));
431
+ };
432
+
433
+ svc.once('up', onUp);
434
+ svc.once('error', onError);
435
+ timeout = setTimeout(onTimeout, this.publishTimeoutMs);
436
+ this.advertiser = svc;
437
+ this.pendingPublishError = (error) => {
438
+ void onError(error);
439
+ };
440
+ if (this.networkError) this.pendingPublishError(new Error(this.networkError));
441
+ } catch (e) {
442
+ safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(e) });
443
+ }
444
+ });
182
445
  });
183
446
  }
184
447
 
@@ -187,6 +450,10 @@ export class mDNS {
187
450
  * @returns Result indicating whether the advertiser is active and error info.
188
451
  */
189
452
  async stopBroadcast(): Promise<MdnsStopResult> {
453
+ return this.withBroadcastLock(() => this.stopBroadcastUnlocked());
454
+ }
455
+
456
+ private async stopBroadcastUnlocked(): Promise<MdnsStopResult> {
190
457
  try {
191
458
  if (this.advertiser) {
192
459
  await this.safeStopService(this.advertiser);
@@ -194,7 +461,7 @@ export class mDNS {
194
461
  }
195
462
  return { publishing: false, error: false, errorMessage: null };
196
463
  } catch (e) {
197
- return { publishing: false, error: true, errorMessage: this.toErr(e) };
464
+ return { publishing: !!this.advertiser, error: true, errorMessage: this.toErr(e) };
198
465
  }
199
466
  }
200
467
 
@@ -205,9 +472,31 @@ export class mDNS {
205
472
  * @param options See MdnsDiscoverOptions for type/name/timeout.
206
473
  */
207
474
  async discover(options: MdnsDiscoverOptions = {}): Promise<MdnsDiscoverResult> {
208
- const { type, protocol } = this.parseType(options.type);
209
- const targetId = options.name || null;
210
- const timeoutMs = options.timeout ?? 3000;
475
+ const safeOptions = (options ?? {}) as Partial<MdnsDiscoverOptions>;
476
+ if (this.destroyed) {
477
+ return { error: true, errorMessage: 'mDNS instance is destroyed', servicesFound: 0, services: [] };
478
+ }
479
+ if (this.networkError) return { error: true, errorMessage: this.networkError, servicesFound: 0, services: [] };
480
+
481
+ const parsed = this.parseType(safeOptions.type);
482
+ if (!parsed) {
483
+ return {
484
+ error: true,
485
+ errorMessage: this.invalidTypeMessage(safeOptions.type),
486
+ servicesFound: 0,
487
+ services: [],
488
+ };
489
+ }
490
+ const inputError =
491
+ this.validateName(safeOptions.name) ??
492
+ (safeOptions.timeout != null && (typeof safeOptions.timeout !== 'number' || !Number.isFinite(safeOptions.timeout))
493
+ ? 'Invalid timeout'
494
+ : null);
495
+ if (inputError) return { error: true, errorMessage: inputError, servicesFound: 0, services: [] };
496
+ for (const abort of [...this.activeDiscoveries]) abort('Discovery replaced by a new request');
497
+ const { type, protocol } = parsed;
498
+ const targetId = typeof safeOptions.name === 'string' && safeOptions.name.trim() ? safeOptions.name.trim() : null;
499
+ const timeoutMs = this.normalizeTimeout(safeOptions.timeout);
211
500
 
212
501
  let browser: Browser | null = null;
213
502
  const services: MdnsService[] = [];
@@ -216,8 +505,17 @@ export class mDNS {
216
505
 
217
506
  return new Promise<MdnsDiscoverResult>((resolve) => {
218
507
  let timer: NodeJS.Timeout | null = null;
508
+ let settled = false;
219
509
 
220
- const finish = () => {
510
+ const finish = (abortMessage?: string) => {
511
+ if (settled) return;
512
+ settled = true;
513
+ this.activeDiscoveries.delete(finish);
514
+ this.transport?.removeListener('response', onResponse);
515
+ if (abortMessage) {
516
+ hadError = true;
517
+ errMsg = abortMessage;
518
+ }
221
519
  if (browser) {
222
520
  this.safeStopBrowser(browser);
223
521
  browser = null;
@@ -229,34 +527,61 @@ export class mDNS {
229
527
  resolve({
230
528
  error: hadError,
231
529
  errorMessage: errMsg,
232
- services,
233
- servicesFound: services.length,
530
+ services: hadError ? [] : services.filter((s) => s.hosts.length > 0),
531
+ servicesFound: hadError ? 0 : services.filter((s) => s.hosts.length > 0).length,
234
532
  });
235
533
  };
236
534
 
535
+ const up = (s: Service) => {
536
+ if (settled) return;
537
+ const item = this.serviceResult(s, this.toFullType(type, protocol));
538
+ if (!this.matchesTarget(item.name, targetId)) return;
539
+ const previous = services.find((x) => x.name === item.name && x.port === item.port);
540
+ if (previous) Object.assign(previous, item, { hosts: this.orderHosts([...previous.hosts, ...item.hosts]) });
541
+ else services.push(item);
542
+ if (targetId && item.hosts.length) finish();
543
+ else if (!item.hosts.length && item.hostname) {
544
+ this.queryAddresses(item.hostname);
545
+ }
546
+ };
547
+ const onResponse = (packet: { answers?: DnsRecord[]; additionals?: DnsRecord[] }) => {
548
+ if (settled) return;
549
+ const records = [...(packet.answers ?? []), ...(packet.additionals ?? [])];
550
+ for (const item of services) {
551
+ for (const record of records) {
552
+ if (
553
+ (record.type === 'A' || record.type === 'AAAA') &&
554
+ record.ttl &&
555
+ typeof record.data === 'string' &&
556
+ record.name.toLowerCase() === item.hostname?.toLowerCase() &&
557
+ !item.hosts.includes(record.data)
558
+ )
559
+ item.hosts = this.orderHosts([...item.hosts, record.data]);
560
+ }
561
+ }
562
+ if (targetId && services.some((item) => item.hosts.length)) finish();
563
+ };
564
+ this.activeDiscoveries.add(finish);
237
565
  try {
238
- browser = this.bonjour.find({ type, protocol }, (s) => {
239
- if (!this.matchesTarget(s.name || '', targetId)) return;
240
- const item: MdnsService = {
241
- name: s.name || '',
242
- type: this.toFullType(type, protocol),
243
- domain: 'local.',
244
- port: s.port ?? 0,
245
- hosts: Array.isArray(s.addresses) ? s.addresses.slice() : [],
246
- txt: s.txt && Object.keys(s.txt).length ? (s.txt as Record<string, string>) : undefined,
247
- };
248
- const key = `${item.name}:${item.port}`;
249
- if (!services.some((x) => `${x.name}:${x.port}` === key)) services.push(item);
250
- if (targetId && this.matchesTarget(item.name, targetId)) finish();
251
- });
252
-
566
+ const bonjour = this.bonjour;
567
+ if (settled) return;
568
+ this.transport?.on('response', onResponse);
569
+ browser = bonjour.find({ type, protocol }, up);
570
+ if (settled) {
571
+ this.safeStopBrowser(browser);
572
+ browser = null;
573
+ return;
574
+ }
575
+ browser.on('srv-update', up);
576
+ browser.on('txt-update', up);
253
577
  // Record error and let the timeout conclude; results will still be returned.
254
- browser.on('error', (err: unknown) => {
578
+ const onBrowserError = (err: unknown) => {
255
579
  hadError = true;
256
580
  errMsg = this.toErr(err);
257
- });
581
+ };
582
+ (browser as EventEmitter).on('error', onBrowserError);
258
583
 
259
- timer = setTimeout(finish, timeoutMs);
584
+ timer = setTimeout(() => finish(), timeoutMs);
260
585
  } catch (err) {
261
586
  hadError = true;
262
587
  errMsg = this.toErr(err);
@@ -264,4 +589,200 @@ export class mDNS {
264
589
  }
265
590
  });
266
591
  }
592
+
593
+ private normalizeTxt(txt: Record<string, unknown>): Record<string, string> {
594
+ return Object.fromEntries(
595
+ Object.entries(txt).map(([key, value]) => [key, Buffer.isBuffer(value) ? value.toString('utf8') : String(value)]),
596
+ );
597
+ }
598
+
599
+ private queryAddresses(hostname: string): void {
600
+ try {
601
+ this.transport?.query?.(hostname, 'A');
602
+ this.transport?.query?.(hostname, 'AAAA');
603
+ } catch (err) {
604
+ this.onNetworkError(new Error(this.toErr(err)));
605
+ }
606
+ }
607
+ private orderHosts(hosts: string[]): string[] {
608
+ return [...new Set(hosts)].sort((a, b) => Number(isIPv4(b)) - Number(isIPv4(a)));
609
+ }
610
+ private serviceResult(s: Service, type: string): MdnsService {
611
+ const suffix = `.${type}local`;
612
+ const name = s.fqdn?.toLowerCase().endsWith(suffix.toLowerCase()) ? s.fqdn.slice(0, -suffix.length) : s.name || '';
613
+ return {
614
+ name,
615
+ type,
616
+ domain: 'local.',
617
+ port: s.port ?? 0,
618
+ hosts: this.orderHosts(Array.isArray(s.addresses) ? s.addresses : []),
619
+ ...(s.host ? { hostname: s.host } : {}),
620
+ ...(s.txt && Object.keys(s.txt).length ? { txt: this.normalizeTxt(s.txt) } : {}),
621
+ };
622
+ }
623
+
624
+ private notify(event: string, data: unknown): void {
625
+ this.emit(event, data);
626
+ for (const win of BrowserWindow?.getAllWindows() ?? []) {
627
+ if (!win.isDestroyed() && !win.webContents.isDestroyed() && this.trustedUrl(win.webContents.getURL())) {
628
+ win.webContents.send(`event-mDNS-${event}`, data);
629
+ win.webContents.send(`mdns:event:${event}`, data);
630
+ }
631
+ }
632
+ }
633
+
634
+ private readonly onNetworkError = (err: Error): void => {
635
+ this.networkError = this.toErr(err);
636
+ this.pendingPublishError?.(err);
637
+ const type = this.watch?.types[0] ?? '';
638
+ const watching = !!this.watch;
639
+ void this.stopWatching();
640
+ for (const abort of [...this.activeDiscoveries]) abort(this.toErr(err));
641
+ if (watching) this.notify('watchError', { type, errorMessage: this.toErr(err), watching: false });
642
+ else console.warn('[mDNS]', err);
643
+ };
644
+
645
+ /** Thanks to Idrimi for the original continuous discovery feature (PR #7). */
646
+ async watchServices(options: MdnsWatchOptions): Promise<MdnsWatchResult> {
647
+ const failed = (message: string): MdnsWatchResult => ({ error: true, errorMessage: message, watchersStarted: 0 });
648
+ if (this.destroyed) return failed('mDNS instance is destroyed');
649
+ if (this.networkError) return failed(this.networkError);
650
+ if (!Array.isArray(options?.types) || !options.types.length) return failed("Missing or empty 'types' array");
651
+ const types = new Map<string, { type: string; protocol: 'tcp' | 'udp' }>();
652
+ for (const raw of options.types) {
653
+ const parsed = typeof raw === 'string' && raw.trim() ? this.parseType(raw) : null;
654
+ if (!parsed) return failed(this.invalidTypeMessage(raw));
655
+ types.set(this.toFullType(parsed.type, parsed.protocol), parsed);
656
+ }
657
+ const cleanup = this.stopWatch();
658
+ if (cleanup) return failed(cleanup);
659
+ const bonjour = this.bonjour;
660
+ if (this.networkError) return failed(this.networkError);
661
+ const watch: Watch = { types: [...types.keys()], browsers: [], services: new Map() };
662
+ this.watch = watch;
663
+ const addBrowser = (type: string, parsed: { type: string; protocol: 'tcp' | 'udp' }) => {
664
+ let active = true;
665
+ const up = (s: Service) => {
666
+ if (this.watch !== watch || !active) return;
667
+ const service = this.serviceResult(s, type);
668
+ const key = `${type}|${service.name}`;
669
+ const previous = watch.services.get(key);
670
+ if (!service.hosts.length && previous?.service.hostname === service.hostname)
671
+ service.hosts = [...(previous?.service.hosts ?? [])];
672
+ const isNew = !previous?.announced && service.hosts.length > 0;
673
+ watch.services.set(key, {
674
+ service,
675
+ fqdn: s.fqdn || `${service.name}.${type}local`,
676
+ expires: s.ttl ? Date.now() + s.ttl * 1000 : Infinity,
677
+ announced: !!previous?.announced || isNew,
678
+ });
679
+ if (!service.hosts.length && service.hostname) this.queryAddresses(service.hostname);
680
+ if (isNew) this.notify('serviceFound', { service: structuredClone(service) });
681
+ };
682
+ const browser = bonjour.find(parsed, up);
683
+ browser.on('down', (s: Service) => {
684
+ if (this.watch !== watch || !active) return;
685
+ const key = `${type}|${this.serviceResult(s, type).name}`;
686
+ const previous = watch.services.get(key);
687
+ watch.services.delete(key);
688
+ if (previous?.announced) this.notify('serviceLost', { service: structuredClone(previous.service) });
689
+ });
690
+ browser.on('srv-update', up);
691
+ browser.on('txt-update', up);
692
+ this.deactivateBrowser.set(browser, () => {
693
+ active = false;
694
+ });
695
+ if (this.watch !== watch) {
696
+ this.stopWatchBrowser(browser);
697
+ return;
698
+ }
699
+ watch.browsers.push(browser);
700
+ };
701
+ try {
702
+ for (const [type, parsed] of types) addBrowser(type, parsed);
703
+ if (this.watch !== watch) return failed('Watch stopped during startup');
704
+ watch.onResponse = (packet) => {
705
+ if (this.watch !== watch) return;
706
+ const records = [...(packet.answers ?? []), ...(packet.additionals ?? [])];
707
+ for (const item of watch.services.values()) {
708
+ const ptr = records.find((r) => r.type === 'PTR' && String(r.data).toLowerCase() === item.fqdn.toLowerCase());
709
+ if (ptr) item.expires = Date.now() + ptr.ttl * 1000;
710
+ const addresses = records.filter(
711
+ (r) =>
712
+ (r.type === 'A' || r.type === 'AAAA') && r.name.toLowerCase() === item.service.hostname?.toLowerCase(),
713
+ );
714
+ for (const address of addresses) {
715
+ if (typeof address.data !== 'string') continue;
716
+ const hosts = item.service.hosts.filter((host) => host !== address.data);
717
+ item.service.hosts = this.orderHosts(address.ttl ? [...hosts, address.data] : hosts);
718
+ }
719
+ if (!item.announced && item.service.hosts.length) {
720
+ item.announced = true;
721
+ this.notify('serviceFound', { service: structuredClone(item.service) });
722
+ if (this.watch !== watch) return;
723
+ }
724
+ }
725
+ };
726
+ this.transport?.on('response', watch.onResponse);
727
+ let ticks = 0;
728
+ watch.timer = setInterval(() => {
729
+ if (this.watch !== watch) return;
730
+ try {
731
+ const expired = [...watch.services.entries()].filter(([, item]) => item.expires <= Date.now());
732
+ for (const [key, item] of expired) {
733
+ watch.services.delete(key);
734
+ if (item.announced) this.notify('serviceLost', { service: structuredClone(item.service) });
735
+ if (this.watch !== watch) return;
736
+ }
737
+ // A fresh Browser forgets expired entries retained by bonjour-service's internal cache.
738
+ if (expired.length) {
739
+ for (const browser of watch.browsers.splice(0)) this.stopWatchBrowser(browser);
740
+ for (const [type, parsed] of types) addBrowser(type, parsed);
741
+ } else if (++ticks % 30 === 0) {
742
+ for (const browser of watch.browsers) browser.update?.();
743
+ }
744
+ } catch (err) {
745
+ this.onNetworkError(err instanceof Error ? err : new Error(this.toErr(err)));
746
+ }
747
+ }, 1000);
748
+ watch.timer.unref();
749
+ return { error: false, errorMessage: null, watchersStarted: types.size };
750
+ } catch (err) {
751
+ this.stopWatch();
752
+ return failed(this.toErr(err));
753
+ }
754
+ }
755
+
756
+ private stopWatchBrowser(browser: Browser): void {
757
+ this.deactivateBrowser.get(browser)?.();
758
+ this.deactivateBrowser.delete(browser);
759
+ try {
760
+ browser.stop();
761
+ this.stoppedBrowsers.delete(browser);
762
+ } catch (err) {
763
+ this.stoppedBrowsers.add(browser);
764
+ throw err;
765
+ }
766
+ }
767
+
768
+ private stopWatch(): string | null {
769
+ const watch = this.watch;
770
+ this.watch = undefined;
771
+ if (watch?.timer) clearInterval(watch.timer);
772
+ if (watch?.onResponse) this.transport?.removeListener('response', watch.onResponse);
773
+ const errors: string[] = [];
774
+ for (const browser of new Set([...(watch?.browsers ?? []), ...this.stoppedBrowsers])) {
775
+ try {
776
+ this.stopWatchBrowser(browser);
777
+ } catch (err) {
778
+ errors.push(this.toErr(err));
779
+ }
780
+ }
781
+ return errors.length ? errors.join('; ') : null;
782
+ }
783
+
784
+ async stopWatching(): Promise<MdnsWatchStopResult> {
785
+ const errorMessage = this.stopWatch();
786
+ return { watching: false, error: !!errorMessage, errorMessage };
787
+ }
267
788
  }