@devioarts/capacitor-mdns 0.1.0 → 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 (43) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DevioartsCapacitorMdns.podspec +3 -3
  3. package/Package.swift +1 -1
  4. package/README.md +221 -37
  5. package/android/build.gradle +3 -9
  6. package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
  7. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
  8. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
  9. package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
  10. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +467 -243
  11. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +80 -57
  12. package/dist/docs.json +250 -14
  13. package/dist/esm/definitions.d.ts +64 -17
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/index.js +22 -2
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/web.d.ts +6 -1
  18. package/dist/esm/web.js +23 -0
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +45 -2
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +45 -2
  23. package/dist/plugin.js.map +1 -1
  24. package/docs/electron.md +29 -3
  25. package/docs/platform-setup.md +6 -0
  26. package/docs/playground.md +33 -2
  27. package/docs/runtime-behavior.md +68 -2
  28. package/electron/dist/plugin-settings.js +10 -3
  29. package/electron/dist/plugin-settings.js.map +1 -1
  30. package/electron/dist/plugin.cjs.js +519 -89
  31. package/electron/dist/plugin.cjs.js.map +1 -1
  32. package/electron/mdns-bridge.cjs +25 -2
  33. package/electron/mdns.ts +514 -81
  34. package/electron/rollup.config.mjs +1 -1
  35. package/electron/src/index.ts +1 -0
  36. package/electron/src/plugin-settings.ts +10 -3
  37. package/electron/types-public/index.d.ts +25 -3
  38. package/ios/Sources/mDNSPlugin/mDNS.swift +407 -117
  39. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +77 -24
  40. package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
  41. package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
  42. package/package.json +11 -4
  43. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -9
package/electron/mdns.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import Bonjour from 'bonjour-service';
2
- 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';
3
7
 
4
8
  import type {
5
9
  MdnsBroadcastOptions,
@@ -9,29 +13,121 @@ import type {
9
13
  MdnsPluginPlatformResult,
10
14
  MdnsService,
11
15
  MdnsStopResult,
16
+ MdnsWatchOptions,
17
+ MdnsWatchResult,
18
+ MdnsWatchStopResult,
12
19
  } from '../src/definitions';
13
20
 
14
21
  type Service = InstanceType<typeof Bonjour.Service>;
15
22
  type Browser = InstanceType<typeof Bonjour.Browser>;
16
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
+ }
54
+
17
55
  /**
18
56
  * Electron main-process implementation of the mDNS/Bonjour functionality.
19
57
  *
20
58
  * Uses the `bonjour-service` package to:
21
59
  * - publish (advertise) a single service;
22
60
  * - discover services of a given type and normalize results to the shared schema;
23
- * - wire the API over Electron's ipcMain (see registerIpc/attachOnReady).
61
+ * - wire the API over Electron's ipcMain through init().
24
62
  *
25
63
  * Notes:
26
64
  * - Instance name matching is normalized and prefix-safe, mirroring iOS/Android behavior.
27
65
  * - TXT records are forwarded as strings when present.
28
66
  */
29
- export class mDNS {
30
- private readonly publishTimeoutMs = 5000;
31
- private readonly stopTimeoutMs = 3000;
67
+ export class mDNS extends EventEmitter {
68
+ private readonly publishTimeoutMs: number;
69
+ private readonly stopTimeoutMs: number;
32
70
  private broadcastQueue: Promise<unknown> = Promise.resolve();
33
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>();
34
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
+ }
35
131
  //<editor-fold desc="Init/Destroy">
36
132
  private ipcRegistered = false;
37
133
  private registerIpc(): void {
@@ -41,13 +137,23 @@ export class mDNS {
41
137
  ipcMain.removeHandler('mdns:stopBroadcast');
42
138
  ipcMain.removeHandler('mdns:discover');
43
139
  ipcMain.removeHandler('mdns:getPluginPlatform');
140
+ ipcMain.removeHandler('mdns:watchServices');
141
+ ipcMain.removeHandler('mdns:stopWatching');
44
142
  } catch {
45
143
  /* ignore */
46
144
  }
47
- ipcMain.handle('mdns:startBroadcast', (_evt, o: MdnsBroadcastOptions) => this.startBroadcast(o));
48
- ipcMain.handle('mdns:stopBroadcast', () => this.stopBroadcast());
49
- ipcMain.handle('mdns:discover', (_evt, o: MdnsDiscoverOptions) => this.discover(o));
50
- ipcMain.handle('mdns:getPluginPlatform', () => this.getPluginPlatform());
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());
51
157
  this.ipcRegistered = true;
52
158
  }
53
159
 
@@ -58,23 +164,30 @@ export class mDNS {
58
164
  ipcMain.removeHandler('mdns:stopBroadcast');
59
165
  ipcMain.removeHandler('mdns:discover');
60
166
  ipcMain.removeHandler('mdns:getPluginPlatform');
167
+ ipcMain.removeHandler('mdns:watchServices');
168
+ ipcMain.removeHandler('mdns:stopWatching');
61
169
  } catch {
62
170
  /* ignore */
63
171
  }
64
172
  this.ipcRegistered = false;
65
173
  }
66
- /** Manual IPC registration (if you set autoRegisterIpc: false). Idempotent. */
174
+ /** Register manual IPC handlers. Idempotent. */
67
175
  init(): void {
68
176
  this.registerIpc();
69
177
  }
70
178
 
71
179
  async destroy(): Promise<void> {
72
180
  this.destroyed = true;
181
+ this.pendingPublishError?.(new Error('mDNS instance destroyed during publish'));
182
+ await this.stopWatching();
73
183
  this.unregisterIpc();
184
+ for (const abort of [...this.activeDiscoveries]) abort('mDNS instance destroyed during discovery');
74
185
  await this.withBroadcastLock(async () => {
75
186
  await this.stopBroadcastUnlocked();
76
187
  try {
77
- this.bonjour.destroy();
188
+ this.bonjourInstance?.destroy();
189
+ this.transport?.removeListener('error', this.onNetworkError);
190
+ this.advertiser = undefined;
78
191
  } catch (err) {
79
192
  console.warn('[mDNS] bonjour.destroy error:', err);
80
193
  }
@@ -83,7 +196,6 @@ export class mDNS {
83
196
 
84
197
  //</editor-fold>
85
198
 
86
- private bonjour = new Bonjour();
87
199
  private advertiser?: Service;
88
200
 
89
201
  // ----------------------------- utils -----------------------------
@@ -106,7 +218,7 @@ export class mDNS {
106
218
  }
107
219
 
108
220
  private normalize(name: string): string {
109
- return name.replace(/ \(\d+\)$/, '');
221
+ return name.replace(/ \(\d+\)$/, '').replace(/\./g, '-');
110
222
  }
111
223
  private matchesTarget(candidate: string, target?: string | null): boolean {
112
224
  if (!target) return true;
@@ -114,42 +226,73 @@ export class mDNS {
114
226
  t = this.normalize(target);
115
227
  return c === t || c.startsWith(t);
116
228
  }
117
- private parseType(typeWithDot?: unknown): { type: string; protocol: '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;
118
235
  const raw = typeof typeWithDot === 'string' && typeWithDot.trim() ? typeWithDot.trim() : '_http._tcp.';
119
- const s = raw.replace(/\.$/, '');
120
- const m = /^_([^.]+)\._(tcp|udp)$/.exec(s);
121
- if (!m) return { type: 'http', protocol: 'tcp' };
122
- return { type: m[1], protocol: m[2] as 'tcp' | 'udp' };
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);
123
270
  }
124
271
  private toFullType(type: string, protocol: 'tcp' | 'udp'): string {
125
272
  return `_${type}._${protocol}.`;
126
273
  }
127
274
 
128
- private hasCbStop(x: unknown): x is { stop: (cb?: () => void) => void } {
129
- return !!x && typeof (x as any).stop === 'function';
130
- }
131
275
  private async safeStopService(svc: Service | undefined): Promise<void> {
132
- if (!svc || !this.hasCbStop(svc)) return;
133
- try {
134
- await new Promise<void>((res) => {
135
- let settled = false;
136
- const done = () => {
137
- if (settled) return;
138
- settled = true;
139
- if (timeout) clearTimeout(timeout);
140
- res();
141
- };
142
- const timeout = setTimeout(done, this.stopTimeoutMs);
143
- try {
144
- svc.stop(done);
145
- } catch (e) {
146
- console.warn('[mDNS] service.stop threw:', e);
147
- done();
148
- }
149
- });
150
- } catch (err) {
151
- console.warn('[mDNS] service.stop error:', err);
152
- }
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
+ });
153
296
  }
154
297
  private safeStopBrowser(b: Browser | null | undefined): void {
155
298
  if (!b) return;
@@ -176,16 +319,31 @@ export class mDNS {
176
319
  async startBroadcast(options: MdnsBroadcastOptions): Promise<MdnsBroadcastResult> {
177
320
  return this.withBroadcastLock(async () => {
178
321
  const safeOptions = (options ?? {}) as Partial<MdnsBroadcastOptions>;
179
- const portError = this.validatePort(safeOptions.port);
180
- if (portError) return { publishing: false, name: '', error: true, errorMessage: portError };
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 };
181
325
  if (this.destroyed)
182
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 };
183
329
 
184
- await this.stopBroadcastUnlocked();
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),
337
+ };
338
+ }
339
+ const { type, protocol } = parsed;
340
+
341
+ const stopped = await this.stopBroadcastUnlocked();
342
+ if (stopped.error) return { ...stopped, name: '' };
185
343
 
186
- const { type, protocol } = this.parseType(safeOptions.type);
187
344
  return new Promise<MdnsBroadcastResult>((resolve) => {
188
345
  let settled = false;
346
+ let failing = false;
189
347
  let timeout: NodeJS.Timeout | null = null;
190
348
  const safeResolve = (r: MdnsBroadcastResult) => {
191
349
  if (!settled) {
@@ -194,6 +352,7 @@ export class mDNS {
194
352
  clearTimeout(timeout);
195
353
  timeout = null;
196
354
  }
355
+ this.pendingPublishError = undefined;
197
356
  resolve(r);
198
357
  }
199
358
  };
@@ -219,31 +378,66 @@ export class mDNS {
219
378
  }
220
379
  };
221
380
  const onUp = () => {
381
+ if (failing || settled) return;
222
382
  cleanupListeners();
223
383
  safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
224
384
  };
225
- const onError = (err: unknown) => {
385
+ const onError = async (err: unknown) => {
386
+ if (failing || settled) return;
387
+ failing = true;
388
+ if (timeout) clearTimeout(timeout);
389
+ timeout = null;
226
390
  cleanupListeners();
227
- void this.safeStopService(svc);
228
- if (this.advertiser === svc) this.advertiser = undefined;
229
- safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(err) });
230
- };
231
- const onTimeout = () => {
232
- cleanupListeners();
233
- void this.safeStopService(svc);
234
- if (this.advertiser === svc) this.advertiser = undefined;
391
+ const stopped = await this.stopBroadcastUnlocked();
235
392
  safeResolve({
236
- publishing: false,
393
+ publishing: stopped.publishing,
237
394
  name: '',
238
395
  error: true,
239
- errorMessage: `Timed out waiting for service publish after ${this.publishTimeoutMs}ms`,
396
+ errorMessage: [this.toErr(err), stopped.errorMessage].filter(Boolean).join('; '),
240
397
  });
241
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
+ };
242
432
 
243
433
  svc.once('up', onUp);
244
434
  svc.once('error', onError);
245
435
  timeout = setTimeout(onTimeout, this.publishTimeoutMs);
246
436
  this.advertiser = svc;
437
+ this.pendingPublishError = (error) => {
438
+ void onError(error);
439
+ };
440
+ if (this.networkError) this.pendingPublishError(new Error(this.networkError));
247
441
  } catch (e) {
248
442
  safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(e) });
249
443
  }
@@ -267,7 +461,7 @@ export class mDNS {
267
461
  }
268
462
  return { publishing: false, error: false, errorMessage: null };
269
463
  } catch (e) {
270
- return { publishing: false, error: true, errorMessage: this.toErr(e) };
464
+ return { publishing: !!this.advertiser, error: true, errorMessage: this.toErr(e) };
271
465
  }
272
466
  }
273
467
 
@@ -282,10 +476,27 @@ export class mDNS {
282
476
  if (this.destroyed) {
283
477
  return { error: true, errorMessage: 'mDNS instance is destroyed', servicesFound: 0, services: [] };
284
478
  }
479
+ if (this.networkError) return { error: true, errorMessage: this.networkError, servicesFound: 0, services: [] };
285
480
 
286
- const { type, protocol } = this.parseType(safeOptions.type);
287
- const targetId = typeof safeOptions.name === 'string' && safeOptions.name ? safeOptions.name : null;
288
- const timeoutMs = typeof safeOptions.timeout === 'number' ? safeOptions.timeout : 3000;
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);
289
500
 
290
501
  let browser: Browser | null = null;
291
502
  const services: MdnsService[] = [];
@@ -296,9 +507,15 @@ export class mDNS {
296
507
  let timer: NodeJS.Timeout | null = null;
297
508
  let settled = false;
298
509
 
299
- const finish = () => {
510
+ const finish = (abortMessage?: string) => {
300
511
  if (settled) return;
301
512
  settled = true;
513
+ this.activeDiscoveries.delete(finish);
514
+ this.transport?.removeListener('response', onResponse);
515
+ if (abortMessage) {
516
+ hadError = true;
517
+ errMsg = abortMessage;
518
+ }
302
519
  if (browser) {
303
520
  this.safeStopBrowser(browser);
304
521
  browser = null;
@@ -310,35 +527,61 @@ export class mDNS {
310
527
  resolve({
311
528
  error: hadError,
312
529
  errorMessage: errMsg,
313
- services,
314
- 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,
315
532
  });
316
533
  };
317
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);
318
565
  try {
319
- browser = this.bonjour.find({ type, protocol }, (s) => {
320
- if (!this.matchesTarget(s.name || '', targetId)) return;
321
- const item: MdnsService = {
322
- name: s.name || '',
323
- type: this.toFullType(type, protocol),
324
- domain: 'local.',
325
- port: s.port ?? 0,
326
- hosts: Array.isArray(s.addresses) ? s.addresses.slice() : [],
327
- txt: s.txt && Object.keys(s.txt).length ? this.normalizeTxt(s.txt) : undefined,
328
- };
329
- const key = `${item.name}:${item.port}`;
330
- if (!services.some((x) => `${x.name}:${x.port}` === key)) services.push(item);
331
- if (targetId && this.matchesTarget(item.name, targetId)) finish();
332
- });
333
-
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);
334
577
  // Record error and let the timeout conclude; results will still be returned.
335
578
  const onBrowserError = (err: unknown) => {
336
579
  hadError = true;
337
580
  errMsg = this.toErr(err);
338
581
  };
339
- (browser as any).on('error', onBrowserError);
582
+ (browser as EventEmitter).on('error', onBrowserError);
340
583
 
341
- timer = setTimeout(finish, Math.max(0, timeoutMs));
584
+ timer = setTimeout(() => finish(), timeoutMs);
342
585
  } catch (err) {
343
586
  hadError = true;
344
587
  errMsg = this.toErr(err);
@@ -352,4 +595,194 @@ export class mDNS {
352
595
  Object.entries(txt).map(([key, value]) => [key, Buffer.isBuffer(value) ? value.toString('utf8') : String(value)]),
353
596
  );
354
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
+ }
355
788
  }