@matter-server/ws-controller 0.6.2 → 0.6.3

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