@matter-server/ws-controller 0.6.3-alpha.0-20260429-7b8104b → 0.6.4

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.
@@ -0,0 +1,608 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { BorderRouterEntry } from "@matter-server/ws-client";
8
+ import { Bytes, type DnsRecord, DnsRecordType, Environment, Logger, type SrvRecordValue } from "@matter/main";
9
+ import { MdnsService } from "@matter/main/protocol";
10
+
11
+ const logger = Logger.get("BorderRouterDiscovery");
12
+
13
+ const REGISTRY_MAX_ENTRIES = 256;
14
+ /** Generous headroom above REGISTRY_MAX_ENTRIES so we still observe instances that haven't
15
+ * yet emitted a valid xa, but bounded so a noisy LAN can't grow `#instanceObservers`
16
+ * without limit. Eviction targets the oldest xa-less observer first. */
17
+ const INSTANCE_OBSERVER_CAP = 512;
18
+ /** Stale entries (sources.length === 0) become eligible for pruning 24h after their
19
+ * last successful mDNS discovery (entry.lastSeen). Pruning is lazy — `#pruneExpired`
20
+ * runs on `list` / `get` / `#onDiscovered`, so without activity an eligible entry
21
+ * may linger past the window. Long enough that BRs announcing once per ~half-day
22
+ * stay resolvable; short enough that vanished BRs eventually drop. */
23
+ const STALE_RETENTION_MS = 24 * 60 * 60 * 1000;
24
+ const MESHCOP_TYPE_QNAME = "_meshcop._udp.local";
25
+ const TREL_TYPE_QNAME = "_trel._udp.local";
26
+ const MESHCOP_SUFFIX = "._meshcop._udp.local";
27
+ const TREL_SUFFIX = "._trel._udp.local";
28
+
29
+ type Source = "meshcop" | "trel";
30
+
31
+ interface DnssdRecordLike {
32
+ recordType: DnsRecordType;
33
+ name: string;
34
+ value: unknown;
35
+ }
36
+
37
+ interface DnssdParametersLike extends ReadonlyMap<string, string> {
38
+ raw(key: string): Bytes | undefined;
39
+ }
40
+
41
+ interface DnssdNameLike {
42
+ readonly qname: string;
43
+ readonly parameters: DnssdParametersLike;
44
+ readonly records: Iterable<DnssdRecordLike>;
45
+ readonly isDiscovered: boolean;
46
+ on(observer: NameObserver): void;
47
+ off(observer: NameObserver): void;
48
+ }
49
+
50
+ interface DnssdNamesFiltersLike {
51
+ add(filter: (record: DnsRecord) => boolean): unknown;
52
+ delete(filter: (record: DnsRecord) => boolean): unknown;
53
+ }
54
+
55
+ interface DiscoveredObservableLike {
56
+ on(observer: DiscoveredObserver): void;
57
+ off(observer: DiscoveredObserver): void;
58
+ }
59
+
60
+ interface SolicitorLike {
61
+ solicit(solicitation: { name: DnssdNameLike; recordTypes: DnsRecordType[] }): void;
62
+ }
63
+
64
+ interface DnssdNamesLike {
65
+ readonly filters: DnssdNamesFiltersLike;
66
+ readonly discovered: DiscoveredObservableLike;
67
+ readonly solicitor: SolicitorLike;
68
+ get(qname: string): DnssdNameLike;
69
+ maybeGet(qname: string): DnssdNameLike | undefined;
70
+ }
71
+
72
+ type DiscoveredObserver = (name: DnssdNameLike) => void;
73
+ type NameObserver = (changes: { name: DnssdNameLike; updated?: unknown[]; deleted?: unknown[] }) => void;
74
+
75
+ interface InstanceTracking {
76
+ name: DnssdNameLike;
77
+ source: Source;
78
+ observer: NameObserver;
79
+ targetKey?: string;
80
+ xaKey?: string;
81
+ /** Set when the observer is attached. Used to evict oldest xa-less observers when
82
+ * `#instanceObservers` grows past {@link INSTANCE_OBSERVER_CAP}. */
83
+ firstSeen: number;
84
+ }
85
+
86
+ interface TargetTracking {
87
+ target: DnssdNameLike;
88
+ observer: NameObserver;
89
+ refcount: number;
90
+ }
91
+
92
+ /**
93
+ * Passive Thread Border Router discovery via mDNS.
94
+ *
95
+ * Subscribes to `_meshcop._udp.local` and `_trel._udp.local`, builds a per-extended-address
96
+ * registry, and exposes the current entries through {@link list}. Owned by {@link MatterController}.
97
+ */
98
+ export class BorderRouterDiscovery {
99
+ readonly #env: Environment;
100
+ readonly #registry = new Map<string, BorderRouterEntry>();
101
+ readonly #instanceObservers = new Map<string, InstanceTracking>();
102
+ readonly #targetObservers = new Map<string, TargetTracking>();
103
+ #names?: DnssdNamesLike;
104
+ #injectedNames?: DnssdNamesLike;
105
+ #suffixFilter?: (record: DnsRecord) => boolean;
106
+ #discoveredObserver?: DiscoveredObserver;
107
+ #started = false;
108
+ /** Incremented on every start/stop. Lets a pending `await mdns.construction.ready`
109
+ * detect that `stop()` ran while it was suspended and abort partial setup. */
110
+ #startGeneration = 0;
111
+ #evictionWarnedThisCycle = false;
112
+
113
+ constructor(env: Environment, names?: DnssdNamesLike) {
114
+ this.#env = env;
115
+ this.#injectedNames = names;
116
+ }
117
+
118
+ async start(): Promise<void> {
119
+ if (this.#started) return;
120
+ const gen = ++this.#startGeneration;
121
+ this.#evictionWarnedThisCycle = false;
122
+
123
+ let names: DnssdNamesLike;
124
+ if (this.#injectedNames !== undefined) {
125
+ names = this.#injectedNames;
126
+ } else {
127
+ try {
128
+ const mdns = this.#env.get(MdnsService);
129
+ await mdns.construction.ready;
130
+ if (gen !== this.#startGeneration) return;
131
+ names = mdns.names;
132
+ } catch (e) {
133
+ if (gen !== this.#startGeneration) return;
134
+ logger.warn("MDNS service unavailable; border router discovery inactive:", e);
135
+ return;
136
+ }
137
+ }
138
+ this.#started = true;
139
+ this.#names = names;
140
+
141
+ const suffixFilter = ({ name }: DnsRecord): boolean => {
142
+ const lower = name.toLowerCase();
143
+ return lower.endsWith(MESHCOP_SUFFIX) || lower.endsWith(TREL_SUFFIX);
144
+ };
145
+ this.#suffixFilter = suffixFilter;
146
+ names.filters.add(suffixFilter);
147
+
148
+ const meshcopType = names.get(MESHCOP_TYPE_QNAME);
149
+ const trelType = names.get(TREL_TYPE_QNAME);
150
+ names.solicitor.solicit({ name: meshcopType, recordTypes: [DnsRecordType.PTR] });
151
+ names.solicitor.solicit({ name: trelType, recordTypes: [DnsRecordType.PTR] });
152
+
153
+ const observer: DiscoveredObserver = name => this.#onDiscovered(name);
154
+ this.#discoveredObserver = observer;
155
+ names.discovered.on(observer);
156
+ }
157
+
158
+ async stop(): Promise<void> {
159
+ // Bump the generation so any in-flight start() exits without attaching observers.
160
+ this.#startGeneration++;
161
+ if (!this.#started) return;
162
+ this.#started = false;
163
+
164
+ const names = this.#names;
165
+ if (names !== undefined) {
166
+ if (this.#discoveredObserver !== undefined) {
167
+ names.discovered.off(this.#discoveredObserver);
168
+ }
169
+ if (this.#suffixFilter !== undefined) {
170
+ names.filters.delete(this.#suffixFilter);
171
+ }
172
+ }
173
+ this.#discoveredObserver = undefined;
174
+ this.#suffixFilter = undefined;
175
+
176
+ for (const tracking of this.#instanceObservers.values()) {
177
+ tracking.name.off(tracking.observer);
178
+ }
179
+ this.#instanceObservers.clear();
180
+
181
+ for (const tracking of this.#targetObservers.values()) {
182
+ tracking.target.off(tracking.observer);
183
+ }
184
+ this.#targetObservers.clear();
185
+
186
+ this.#registry.clear();
187
+ this.#names = undefined;
188
+ }
189
+
190
+ list(): BorderRouterEntry[] {
191
+ this.#pruneExpired();
192
+ return Array.from(this.#registry.values(), entry => this.#snapshotEntry(entry));
193
+ }
194
+
195
+ get(extAddressHex: string): BorderRouterEntry | undefined {
196
+ this.#pruneExpired();
197
+ const entry = this.#registry.get(extAddressHex.toUpperCase());
198
+ return entry === undefined ? undefined : this.#snapshotEntry(entry);
199
+ }
200
+
201
+ #pruneExpired(): void {
202
+ const cutoff = Date.now() - STALE_RETENTION_MS;
203
+ for (const [xaKey, entry] of this.#registry) {
204
+ if (entry.sources.length === 0 && entry.lastSeen < cutoff) {
205
+ this.#registry.delete(xaKey);
206
+ }
207
+ }
208
+ }
209
+
210
+ /** Shallow copy so callers cannot mutate registry state through the returned reference. */
211
+ #snapshotEntry(entry: BorderRouterEntry): BorderRouterEntry {
212
+ return {
213
+ ...entry,
214
+ sources: [...entry.sources],
215
+ addresses: [...entry.addresses],
216
+ };
217
+ }
218
+
219
+ #onDiscovered(name: DnssdNameLike): void {
220
+ if (!this.#started) return;
221
+ this.#pruneExpired();
222
+ const lower = name.qname.toLowerCase();
223
+ if (lower === MESHCOP_TYPE_QNAME || lower === TREL_TYPE_QNAME) {
224
+ return;
225
+ }
226
+ let source: Source;
227
+ if (lower.endsWith(MESHCOP_SUFFIX)) {
228
+ source = "meshcop";
229
+ } else if (lower.endsWith(TREL_SUFFIX)) {
230
+ source = "trel";
231
+ } else {
232
+ return;
233
+ }
234
+
235
+ const key = lower;
236
+ if (this.#instanceObservers.has(key)) {
237
+ return;
238
+ }
239
+
240
+ if (this.#instanceObservers.size >= INSTANCE_OBSERVER_CAP) {
241
+ // Drop xa-less observers first (cheapest to lose); if every observer already
242
+ // has an xa, fall back to evicting the oldest registry entry, which detaches
243
+ // its instance observers via the same path. Repeat until under the cap so
244
+ // the cap is strictly enforced even on a flood of valid-xa instances.
245
+ while (this.#instanceObservers.size >= INSTANCE_OBSERVER_CAP) {
246
+ if (!this.#evictOldestPendingInstance() && !this.#evictOldest()) break;
247
+ }
248
+ }
249
+
250
+ const observer: NameObserver = () => this.#onInstanceChanged(name, source);
251
+ this.#instanceObservers.set(key, { name, source, observer, firstSeen: Date.now() });
252
+ name.on(observer);
253
+
254
+ this.#parseAndUpsert(name, source);
255
+ }
256
+
257
+ /**
258
+ * Evict the oldest xa-less instance observer when the observer cap is hit. Instances
259
+ * that never publish a valid `xa` (malformed broadcasters or hostile noise) would
260
+ * otherwise pin observers in `#instanceObservers` indefinitely — eviction targets only
261
+ * those because observers tied to real registry entries are managed by `#evictOldest`.
262
+ */
263
+ #evictOldestPendingInstance(): boolean {
264
+ let oldestKey: string | undefined;
265
+ let oldestSeen = Number.POSITIVE_INFINITY;
266
+ for (const [k, t] of this.#instanceObservers) {
267
+ if (t.xaKey !== undefined) continue;
268
+ if (t.firstSeen < oldestSeen) {
269
+ oldestSeen = t.firstSeen;
270
+ oldestKey = k;
271
+ }
272
+ }
273
+ if (oldestKey === undefined) return false;
274
+ const tracking = this.#instanceObservers.get(oldestKey);
275
+ if (tracking === undefined) return false;
276
+ tracking.name.off(tracking.observer);
277
+ if (tracking.targetKey !== undefined) {
278
+ this.#releaseTarget(tracking.targetKey);
279
+ }
280
+ this.#instanceObservers.delete(oldestKey);
281
+ return true;
282
+ }
283
+
284
+ #onInstanceChanged(name: DnssdNameLike, source: Source): void {
285
+ if (!this.#started) return;
286
+ if (name.isDiscovered) {
287
+ try {
288
+ this.#parseAndUpsert(name, source);
289
+ } catch (e) {
290
+ logger.debug("Error processing border router instance change:", e);
291
+ }
292
+ return;
293
+ }
294
+
295
+ const key = name.qname.toLowerCase();
296
+ const tracking = this.#instanceObservers.get(key);
297
+ if (tracking === undefined) {
298
+ return;
299
+ }
300
+ tracking.name.off(tracking.observer);
301
+ this.#instanceObservers.delete(key);
302
+
303
+ if (tracking.targetKey !== undefined) {
304
+ this.#releaseTarget(tracking.targetKey);
305
+ }
306
+
307
+ const xaKey = tracking.xaKey;
308
+ if (xaKey === undefined) {
309
+ return;
310
+ }
311
+ const entry = this.#registry.get(xaKey);
312
+ if (entry === undefined) {
313
+ return;
314
+ }
315
+ const idx = entry.sources.indexOf(source);
316
+ if (idx !== -1) {
317
+ entry.sources.splice(idx, 1);
318
+ }
319
+ }
320
+
321
+ #onTargetChanged(target: DnssdNameLike): void {
322
+ if (!this.#started) return;
323
+ try {
324
+ const targetQname = target.qname.toLowerCase();
325
+ for (const entry of this.#registry.values()) {
326
+ if (entry.hostname?.toLowerCase() === targetQname) {
327
+ entry.addresses = this.#sortAddresses(this.#collectAddresses(target));
328
+ }
329
+ }
330
+ } catch (e) {
331
+ logger.debug("Error processing border router target change:", e);
332
+ }
333
+ }
334
+
335
+ #parseAndUpsert(name: DnssdNameLike, source: Source): void {
336
+ const names = this.#names;
337
+ if (names === undefined) return;
338
+
339
+ try {
340
+ const params = name.parameters;
341
+ const xaKey = rawHex(params.raw("xa"), 8);
342
+ if (xaKey === undefined) {
343
+ return;
344
+ }
345
+
346
+ const records = Array.from(name.records);
347
+ const srvRecord = records.find(r => r.recordType === DnsRecordType.SRV);
348
+ let srvTarget: string | undefined;
349
+ let srvPort: number | undefined;
350
+ if (srvRecord !== undefined && isSrvValue(srvRecord.value)) {
351
+ srvTarget = srvRecord.value.target;
352
+ srvPort = srvRecord.value.port;
353
+ }
354
+
355
+ let addresses: string[] = [];
356
+ const tracking = this.#instanceObservers.get(name.qname.toLowerCase());
357
+ if (srvTarget !== undefined) {
358
+ const target = names.get(srvTarget);
359
+ addresses = this.#sortAddresses(this.#collectAddresses(target));
360
+ this.#attachTargetObserver(name, srvTarget, target);
361
+ } else if (tracking?.targetKey !== undefined) {
362
+ // Update dropped the SRV record. Release the previously-attached target
363
+ // observer so its refcount is decremented and the entry doesn't keep
364
+ // receiving address updates for a hostname this instance no longer points at.
365
+ this.#releaseTarget(tracking.targetKey);
366
+ tracking.targetKey = undefined;
367
+ }
368
+
369
+ const xp = rawHex(params.raw("xp"), 8);
370
+
371
+ const existing = this.#registry.get(xaKey);
372
+ const meshcopWins = source === "meshcop";
373
+ const entry: BorderRouterEntry = existing ?? {
374
+ extAddressHex: xaKey,
375
+ addresses: [],
376
+ sources: [],
377
+ lastSeen: Date.now(),
378
+ };
379
+
380
+ const meshcopAlreadyContributed = entry.sources.includes("meshcop");
381
+ const canOverwrite = meshcopWins || !meshcopAlreadyContributed;
382
+
383
+ if (xp !== undefined && canOverwrite) {
384
+ entry.extendedPanIdHex = xp;
385
+ }
386
+
387
+ const previousHostname = entry.hostname;
388
+ if (srvTarget !== undefined && canOverwrite) {
389
+ entry.hostname = srvTarget;
390
+ }
391
+
392
+ if (
393
+ source === "meshcop" &&
394
+ srvTarget !== undefined &&
395
+ previousHostname !== undefined &&
396
+ previousHostname.toLowerCase() !== srvTarget.toLowerCase()
397
+ ) {
398
+ this.#repointTrelTargetForXa(xaKey, previousHostname, srvTarget, names);
399
+ }
400
+
401
+ if (addresses.length > 0 && canOverwrite) {
402
+ entry.addresses = addresses;
403
+ }
404
+
405
+ if (source === "meshcop") {
406
+ if (srvPort !== undefined) entry.meshcopPort = srvPort;
407
+ const nn = params.get("nn");
408
+ if (nn !== undefined) entry.networkName = nn;
409
+ const vn = params.get("vn");
410
+ if (vn !== undefined) entry.vendorName = vn;
411
+ const mn = params.get("mn");
412
+ if (mn !== undefined) entry.modelName = mn;
413
+ const tv = params.get("tv");
414
+ if (tv !== undefined) entry.threadVersion = tv;
415
+ // dd (border-agent ID) is variable-width per spec; xa/xp/at = 8 bytes,
416
+ // pt/sb = 4 bytes (Thread MeshCoP). Reject malformed lengths for fixed
417
+ // fields so a malformed broadcaster can't pollute the snapshot.
418
+ const dd = rawHex(params.raw("dd"));
419
+ if (dd !== undefined) entry.borderAgentIdHex = dd;
420
+ const sb = rawHex(params.raw("sb"), 4);
421
+ if (sb !== undefined) entry.stateBitmapHex = sb;
422
+ const at = rawHex(params.raw("at"), 8);
423
+ if (at !== undefined) entry.activeTimestampHex = at;
424
+ const pt = rawHex(params.raw("pt"), 4);
425
+ if (pt !== undefined) entry.partitionIdHex = pt;
426
+ const dn = params.get("dn");
427
+ if (dn !== undefined) entry.domainName = dn;
428
+ } else if (source === "trel") {
429
+ if (srvPort !== undefined) entry.trelPort = srvPort;
430
+ }
431
+
432
+ if (!entry.sources.includes(source)) {
433
+ if (source === "meshcop") {
434
+ entry.sources.unshift(source);
435
+ } else {
436
+ entry.sources.push(source);
437
+ }
438
+ }
439
+ entry.lastSeen = Date.now();
440
+
441
+ if (existing === undefined) {
442
+ if (this.#registry.size >= REGISTRY_MAX_ENTRIES) {
443
+ this.#evictOldest();
444
+ }
445
+ this.#registry.set(xaKey, entry);
446
+ }
447
+
448
+ if (tracking !== undefined) {
449
+ tracking.xaKey = xaKey;
450
+ }
451
+ } catch (e) {
452
+ logger.debug("Error parsing border router record:", e);
453
+ }
454
+ }
455
+
456
+ #collectAddresses(target: DnssdNameLike): string[] {
457
+ const out = new Array<string>();
458
+ for (const record of target.records) {
459
+ if (record.recordType !== DnsRecordType.A && record.recordType !== DnsRecordType.AAAA) continue;
460
+ if (typeof record.value === "string") {
461
+ out.push(record.value);
462
+ }
463
+ }
464
+ return out;
465
+ }
466
+
467
+ #sortAddresses(addresses: string[]): string[] {
468
+ const seen = new Set<string>();
469
+ const unique = new Array<string>();
470
+ for (const addr of addresses) {
471
+ if (!seen.has(addr)) {
472
+ seen.add(addr);
473
+ unique.push(addr);
474
+ }
475
+ }
476
+ const ipv4 = unique.filter(a => !a.includes(":"));
477
+ const ipv6 = unique.filter(a => a.includes(":"));
478
+ const categorize = (a: string): number => {
479
+ const lower = a.toLowerCase();
480
+ if (lower.startsWith("fe80:")) return 2;
481
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return 1;
482
+ const firstChar = lower.charAt(0);
483
+ if (firstChar === "2" || firstChar === "3") return 0;
484
+ return 3;
485
+ };
486
+ ipv6.sort((a, b) => {
487
+ const ca = categorize(a);
488
+ const cb = categorize(b);
489
+ if (ca !== cb) return ca - cb;
490
+ return a.localeCompare(b);
491
+ });
492
+ ipv4.sort((a, b) => a.localeCompare(b));
493
+ return [...ipv4, ...ipv6];
494
+ }
495
+
496
+ #attachTargetObserver(instance: DnssdNameLike, srvTarget: string, target: DnssdNameLike): void {
497
+ const targetKey = srvTarget.toLowerCase();
498
+ const instanceKey = instance.qname.toLowerCase();
499
+ const instanceTracking = this.#instanceObservers.get(instanceKey);
500
+ const previousTargetKey = instanceTracking?.targetKey;
501
+
502
+ if (previousTargetKey === targetKey) {
503
+ return;
504
+ }
505
+
506
+ if (previousTargetKey !== undefined) {
507
+ this.#releaseTarget(previousTargetKey);
508
+ }
509
+
510
+ const existing = this.#targetObservers.get(targetKey);
511
+ if (existing !== undefined) {
512
+ existing.refcount++;
513
+ } else {
514
+ const observer: NameObserver = () => this.#onTargetChanged(target);
515
+ target.on(observer);
516
+ this.#targetObservers.set(targetKey, { target, observer, refcount: 1 });
517
+ }
518
+ if (instanceTracking !== undefined) {
519
+ instanceTracking.targetKey = targetKey;
520
+ }
521
+ }
522
+
523
+ #repointTrelTargetForXa(xaKey: string, previousHostname: string, newTarget: string, names: DnssdNamesLike): void {
524
+ const previousKey = previousHostname.toLowerCase();
525
+ for (const tracking of this.#instanceObservers.values()) {
526
+ if (tracking.xaKey !== xaKey) continue;
527
+ if (tracking.source !== "trel") continue;
528
+ if (tracking.targetKey !== previousKey) continue;
529
+ this.#attachTargetObserver(tracking.name, newTarget, names.get(newTarget));
530
+ }
531
+ }
532
+
533
+ #releaseTarget(targetKey: string): void {
534
+ const tracking = this.#targetObservers.get(targetKey);
535
+ if (tracking === undefined) return;
536
+ tracking.refcount--;
537
+ if (tracking.refcount <= 0) {
538
+ tracking.target.off(tracking.observer);
539
+ this.#targetObservers.delete(targetKey);
540
+ }
541
+ }
542
+
543
+ #evictOldest(): boolean {
544
+ let oldestStaleKey: string | undefined;
545
+ let oldestStaleSeen = Number.POSITIVE_INFINITY;
546
+ let oldestLiveKey: string | undefined;
547
+ let oldestLiveSeen = Number.POSITIVE_INFINITY;
548
+ for (const [xa, entry] of this.#registry) {
549
+ if (entry.sources.length === 0) {
550
+ if (entry.lastSeen < oldestStaleSeen) {
551
+ oldestStaleSeen = entry.lastSeen;
552
+ oldestStaleKey = xa;
553
+ }
554
+ } else if (entry.lastSeen < oldestLiveSeen) {
555
+ oldestLiveSeen = entry.lastSeen;
556
+ oldestLiveKey = xa;
557
+ }
558
+ }
559
+ const evictKey = oldestStaleKey ?? oldestLiveKey;
560
+ if (evictKey === undefined) return false;
561
+
562
+ this.#registry.delete(evictKey);
563
+
564
+ let releasedObservers = 0;
565
+ for (const [instanceKey, tracking] of [...this.#instanceObservers]) {
566
+ if (tracking.xaKey !== evictKey) continue;
567
+ tracking.name.off(tracking.observer);
568
+ if (tracking.targetKey !== undefined) {
569
+ this.#releaseTarget(tracking.targetKey);
570
+ }
571
+ this.#instanceObservers.delete(instanceKey);
572
+ releasedObservers++;
573
+ }
574
+
575
+ if (!this.#evictionWarnedThisCycle) {
576
+ this.#evictionWarnedThisCycle = true;
577
+ logger.warn(
578
+ `Border router registry exceeded ${REGISTRY_MAX_ENTRIES} entries; evicting oldest (released ${releasedObservers} instance observer${releasedObservers === 1 ? "" : "s"})`,
579
+ );
580
+ } else {
581
+ logger.debug(`Evicted border router xa=${evictKey}; released ${releasedObservers} instance observers`);
582
+ }
583
+ return true;
584
+ }
585
+ }
586
+
587
+ function isSrvValue(value: unknown): value is SrvRecordValue {
588
+ return (
589
+ typeof value === "object" &&
590
+ value !== null &&
591
+ "target" in value &&
592
+ typeof value.target === "string" &&
593
+ "port" in value &&
594
+ typeof value.port === "number"
595
+ );
596
+ }
597
+
598
+ /**
599
+ * MeshCoP TXT records carry binary-valued fields (xa, xp, at, pt, dd, sb) — raw bytes per
600
+ * Thread spec. Bytes.toHex returns lowercase; callers can require an exact byte length so
601
+ * malformed broadcasters can't pollute the registry with non-canonical xa keys (the
602
+ * dashboard's xa→xa join expects uppercase 16-char hex, i.e. 8 bytes).
603
+ */
604
+ function rawHex(bytes: Bytes | undefined, expectedByteLength?: number): string | undefined {
605
+ if (bytes === undefined || bytes.byteLength === 0) return undefined;
606
+ if (expectedByteLength !== undefined && bytes.byteLength !== expectedByteLength) return undefined;
607
+ return Bytes.toHex(bytes).toUpperCase();
608
+ }
@@ -373,13 +373,18 @@ export class ControllerCommandHandler {
373
373
  attributeCache.update(node);
374
374
  }
375
375
  basicInfoChangedInBatch = false;
376
+ // Emit node_updated first so consumers see the new endpoint in node.endpoints,
377
+ // then drain any endpoint_added events queued since the previous structure change.
376
378
  this.events.nodeStructureChanged.emit(nodeId);
379
+ for (const endpointId of this.#nodes.drainPendingEndpointAdds(nodeId)) {
380
+ this.events.nodeEndpointAdded.emit(nodeId, endpointId);
381
+ }
377
382
  });
378
383
  node.events.decommissioned.on(() => {
379
384
  this.#cleanupNodeAfterRemoval(nodeId);
380
385
  this.events.nodeDecommissioned.emit(nodeId);
381
386
  });
382
- node.events.nodeEndpointAdded.on(endpointId => this.events.nodeEndpointAdded.emit(nodeId, endpointId));
387
+ node.events.nodeEndpointAdded.on(endpointId => this.#nodes.queueEndpointAdded(nodeId, endpointId));
383
388
  node.events.nodeEndpointRemoved.on(endpointId => this.events.nodeEndpointRemoved.emit(nodeId, endpointId));
384
389
 
385
390
  // Store the node for direct access
@@ -23,6 +23,7 @@ import { VendorId } from "@matter/main/types";
23
23
  import { CommissioningController } from "@project-chip/matter.js";
24
24
  import { Readable } from "node:stream";
25
25
  import { ConfigStorage } from "../server/ConfigStorage.js";
26
+ import { BorderRouterDiscovery } from "./BorderRouterDiscovery.js";
26
27
  import { ControllerCommandHandler } from "./ControllerCommandHandler.js";
27
28
  import { LegacyDataInjector, LegacyServerData } from "./LegacyDataInjector.js";
28
29
  import { resolveServerId } from "./ServerIdResolver.js";
@@ -75,6 +76,7 @@ export class MatterController {
75
76
  #legacyCommissionedDates?: Map<string, Timestamp>;
76
77
  #enableTestNetDcl = false;
77
78
  #disableOtaProvider = true;
79
+ readonly #borderRouterDiscovery: BorderRouterDiscovery;
78
80
 
79
81
  static async create(
80
82
  environment: Environment,
@@ -144,6 +146,7 @@ export class MatterController {
144
146
 
145
147
  constructor(environment: Environment, config: ConfigStorage, options: MatterControllerOptions, serverId: string) {
146
148
  this.#env = environment;
149
+ this.#borderRouterDiscovery = new BorderRouterDiscovery(this.#env);
147
150
  this.#config = config;
148
151
  this.#serverId = serverId;
149
152
  this.#serverVersion = options.serverVersion ?? "0.0.0";
@@ -210,6 +213,8 @@ export class MatterController {
210
213
  initPromises.push(this.#enableTestOtaImages());
211
214
  }
212
215
 
216
+ initPromises.push(this.#borderRouterDiscovery.start());
217
+
213
218
  try {
214
219
  await MatterAggregateError.allSettled(initPromises);
215
220
  } catch (error) {
@@ -221,6 +226,10 @@ export class MatterController {
221
226
  return this.#commandHandler;
222
227
  }
223
228
 
229
+ get borderRouters(): BorderRouterDiscovery {
230
+ return this.#borderRouterDiscovery;
231
+ }
232
+
224
233
  /**
225
234
  * Get the DCL vendor info service instance.
226
235
  * Lazily initializes the service if not already present.
@@ -294,6 +303,7 @@ export class MatterController {
294
303
  }
295
304
 
296
305
  async stop() {
306
+ await this.#borderRouterDiscovery.stop();
297
307
  await this.#commandHandler?.close(); // This closes also the controller instance if started
298
308
  }
299
309