@matter/general 0.16.8-alpha.0-20260123-dff2cae52 → 0.16.8-alpha.0-20260127-65e1b40e2

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 (94) hide show
  1. package/dist/cjs/net/ServerAddress.d.ts.map +1 -1
  2. package/dist/cjs/net/ServerAddress.js +2 -1
  3. package/dist/cjs/net/ServerAddress.js.map +1 -1
  4. package/dist/cjs/net/dns-sd/DnssdName.d.ts +56 -0
  5. package/dist/cjs/net/dns-sd/DnssdName.d.ts.map +1 -0
  6. package/dist/cjs/net/dns-sd/DnssdName.js +193 -0
  7. package/dist/cjs/net/dns-sd/DnssdName.js.map +6 -0
  8. package/dist/cjs/net/dns-sd/DnssdNames.d.ts +77 -0
  9. package/dist/cjs/net/dns-sd/DnssdNames.d.ts.map +1 -0
  10. package/dist/cjs/net/dns-sd/DnssdNames.js +238 -0
  11. package/dist/cjs/net/dns-sd/DnssdNames.js.map +6 -0
  12. package/dist/cjs/net/dns-sd/DnssdSolicitor.d.ts +80 -0
  13. package/dist/cjs/net/dns-sd/DnssdSolicitor.d.ts.map +1 -0
  14. package/dist/cjs/net/dns-sd/DnssdSolicitor.js +212 -0
  15. package/dist/cjs/net/dns-sd/DnssdSolicitor.js.map +6 -0
  16. package/dist/cjs/net/dns-sd/IpService.d.ts +73 -0
  17. package/dist/cjs/net/dns-sd/IpService.d.ts.map +1 -0
  18. package/dist/cjs/net/dns-sd/IpService.js +329 -0
  19. package/dist/cjs/net/dns-sd/IpService.js.map +6 -0
  20. package/dist/cjs/net/dns-sd/IpServiceResolution.d.ts +16 -0
  21. package/dist/cjs/net/dns-sd/IpServiceResolution.d.ts.map +1 -0
  22. package/dist/cjs/net/dns-sd/IpServiceResolution.js +162 -0
  23. package/dist/cjs/net/dns-sd/IpServiceResolution.js.map +6 -0
  24. package/dist/cjs/net/dns-sd/IpServiceStatus.d.ts +58 -0
  25. package/dist/cjs/net/dns-sd/IpServiceStatus.d.ts.map +1 -0
  26. package/dist/cjs/net/dns-sd/IpServiceStatus.js +191 -0
  27. package/dist/cjs/net/dns-sd/IpServiceStatus.js.map +6 -0
  28. package/dist/cjs/net/dns-sd/index.d.ts +6 -0
  29. package/dist/cjs/net/dns-sd/index.d.ts.map +1 -1
  30. package/dist/cjs/net/dns-sd/index.js +6 -0
  31. package/dist/cjs/net/dns-sd/index.js.map +1 -1
  32. package/dist/cjs/net/udp/UdpInterface.js +1 -1
  33. package/dist/cjs/net/udp/UdpInterface.js.map +1 -1
  34. package/dist/cjs/util/AsyncIterator.d.ts +21 -0
  35. package/dist/cjs/util/AsyncIterator.d.ts.map +1 -0
  36. package/dist/cjs/util/AsyncIterator.js +71 -0
  37. package/dist/cjs/util/AsyncIterator.js.map +6 -0
  38. package/dist/cjs/util/index.d.ts +1 -0
  39. package/dist/cjs/util/index.d.ts.map +1 -1
  40. package/dist/cjs/util/index.js +1 -0
  41. package/dist/cjs/util/index.js.map +1 -1
  42. package/dist/esm/net/ServerAddress.d.ts.map +1 -1
  43. package/dist/esm/net/ServerAddress.js +2 -1
  44. package/dist/esm/net/ServerAddress.js.map +1 -1
  45. package/dist/esm/net/dns-sd/DnssdName.d.ts +56 -0
  46. package/dist/esm/net/dns-sd/DnssdName.d.ts.map +1 -0
  47. package/dist/esm/net/dns-sd/DnssdName.js +173 -0
  48. package/dist/esm/net/dns-sd/DnssdName.js.map +6 -0
  49. package/dist/esm/net/dns-sd/DnssdNames.d.ts +77 -0
  50. package/dist/esm/net/dns-sd/DnssdNames.d.ts.map +1 -0
  51. package/dist/esm/net/dns-sd/DnssdNames.js +218 -0
  52. package/dist/esm/net/dns-sd/DnssdNames.js.map +6 -0
  53. package/dist/esm/net/dns-sd/DnssdSolicitor.d.ts +80 -0
  54. package/dist/esm/net/dns-sd/DnssdSolicitor.d.ts.map +1 -0
  55. package/dist/esm/net/dns-sd/DnssdSolicitor.js +192 -0
  56. package/dist/esm/net/dns-sd/DnssdSolicitor.js.map +6 -0
  57. package/dist/esm/net/dns-sd/IpService.d.ts +73 -0
  58. package/dist/esm/net/dns-sd/IpService.d.ts.map +1 -0
  59. package/dist/esm/net/dns-sd/IpService.js +309 -0
  60. package/dist/esm/net/dns-sd/IpService.js.map +6 -0
  61. package/dist/esm/net/dns-sd/IpServiceResolution.d.ts +16 -0
  62. package/dist/esm/net/dns-sd/IpServiceResolution.d.ts.map +1 -0
  63. package/dist/esm/net/dns-sd/IpServiceResolution.js +142 -0
  64. package/dist/esm/net/dns-sd/IpServiceResolution.js.map +6 -0
  65. package/dist/esm/net/dns-sd/IpServiceStatus.d.ts +58 -0
  66. package/dist/esm/net/dns-sd/IpServiceStatus.d.ts.map +1 -0
  67. package/dist/esm/net/dns-sd/IpServiceStatus.js +171 -0
  68. package/dist/esm/net/dns-sd/IpServiceStatus.js.map +6 -0
  69. package/dist/esm/net/dns-sd/index.d.ts +6 -0
  70. package/dist/esm/net/dns-sd/index.d.ts.map +1 -1
  71. package/dist/esm/net/dns-sd/index.js +6 -0
  72. package/dist/esm/net/dns-sd/index.js.map +1 -1
  73. package/dist/esm/net/udp/UdpInterface.js +1 -1
  74. package/dist/esm/net/udp/UdpInterface.js.map +1 -1
  75. package/dist/esm/util/AsyncIterator.d.ts +21 -0
  76. package/dist/esm/util/AsyncIterator.d.ts.map +1 -0
  77. package/dist/esm/util/AsyncIterator.js +51 -0
  78. package/dist/esm/util/AsyncIterator.js.map +6 -0
  79. package/dist/esm/util/index.d.ts +1 -0
  80. package/dist/esm/util/index.d.ts.map +1 -1
  81. package/dist/esm/util/index.js +1 -0
  82. package/dist/esm/util/index.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/net/ServerAddress.ts +2 -1
  85. package/src/net/dns-sd/DnssdName.ts +252 -0
  86. package/src/net/dns-sd/DnssdNames.ts +208 -0
  87. package/src/net/dns-sd/DnssdSolicitor.ts +231 -0
  88. package/src/net/dns-sd/IpService.ts +346 -0
  89. package/src/net/dns-sd/IpServiceResolution.ts +134 -0
  90. package/src/net/dns-sd/IpServiceStatus.ts +212 -0
  91. package/src/net/dns-sd/index.ts +6 -0
  92. package/src/net/udp/UdpInterface.ts +1 -1
  93. package/src/util/AsyncIterator.ts +70 -0
  94. package/src/util/index.ts +1 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { DnsRecord, DnsRecordType, SrvRecordValue } from "#codec/DnsCodec.js";
8
+ import { Logger } from "#log/Logger.js";
9
+ import { Time } from "#time/Time.js";
10
+ import type { Timestamp } from "#time/Timestamp.js";
11
+ import { AsyncObserver, BasicObservable } from "#util/Observable.js";
12
+ import { MaybePromise } from "#util/Promises.js";
13
+ import type { DnssdNames } from "./DnssdNames.js";
14
+
15
+ const logger = Logger.get("DnssdName");
16
+
17
+ /**
18
+ * Manages records associated with a single DNS-SD qname.
19
+ *
20
+ * Every DNS-SD qname of interest has a 1:1 relationship with a single instance of this class in the context of a
21
+ * {@link DnssdNames}. We therefore can use the qname or {@link DnssdName} interchangeably.
22
+ *
23
+ * An {@link DnssdName} is created when a new name is discovered or requested by another component. The name
24
+ * automatically deletes when there are no longer observers or unexpired records.
25
+ */
26
+ export class DnssdName extends BasicObservable<[changes: DnssdName.Changes], MaybePromise> {
27
+ #context: DnssdName.Context;
28
+ #records = new Map<string, DnssdName.Record>();
29
+ #recordCount = 0;
30
+ #changes?: Map<string, { kind: "update" | "delete"; record: DnssdName.Record }>;
31
+ #notified?: Promise<void>;
32
+ #maybeDeleting?: Promise<void>;
33
+ #parameters?: Map<string, string>;
34
+
35
+ constructor(
36
+ readonly qname: string,
37
+ context: DnssdName.Context,
38
+ ) {
39
+ super(e => logger.error(`Unhandled error in observer for DNS name "${qname}":`, e));
40
+ this.#context = context;
41
+ }
42
+
43
+ override off(observer: AsyncObserver<[]>) {
44
+ super.off(observer);
45
+ this.#deleteIfUnused();
46
+ }
47
+
48
+ async close() {
49
+ if (this.#notified) {
50
+ await this.#notified;
51
+ }
52
+ if (this.#maybeDeleting) {
53
+ await this.#maybeDeleting;
54
+ }
55
+ }
56
+
57
+ get records() {
58
+ return this.#records.values();
59
+ }
60
+
61
+ get parameters() {
62
+ if (this.#parameters === undefined) {
63
+ this.#parameters = new Map();
64
+ }
65
+ return this.#parameters;
66
+ }
67
+
68
+ get isDiscovered() {
69
+ return !!this.#recordCount;
70
+ }
71
+
72
+ installRecord(record: DnsRecord<any>) {
73
+ // For TXT records, extract the standard DNS-SD k/v's
74
+ if (record.recordType === DnsRecordType.TXT) {
75
+ const entries = record.value;
76
+ for (const entry of entries) {
77
+ const pos = entry.indexOf("=");
78
+ if (pos === -1) {
79
+ this.parameters.set(entry, "");
80
+ } else {
81
+ this.parameters.set(entry.slice(0, pos), entry.slice(pos + 1));
82
+ }
83
+ }
84
+ }
85
+
86
+ const key = keyOf(record);
87
+ if (key === undefined) {
88
+ this.#deleteIfUnused();
89
+ return false;
90
+ }
91
+
92
+ const oldRecord = this.#records.get(key);
93
+ if (oldRecord) {
94
+ this.#context.unregisterForExpiration(oldRecord);
95
+ } else {
96
+ this.#recordCount++;
97
+ }
98
+
99
+ const recordWithExpire = { ...record, expiresAt: Time.nowMs + record.ttl } as DnssdName.Record;
100
+
101
+ this.#records.set(key, recordWithExpire);
102
+
103
+ this.#context.registerForExpiration(recordWithExpire);
104
+
105
+ this.#notify("update", key, recordWithExpire);
106
+ }
107
+
108
+ deleteRecord(record: DnsRecord, ifOlderThan?: Timestamp) {
109
+ const key = keyOf(record);
110
+ if (key === undefined) {
111
+ this.#deleteIfUnused();
112
+ return;
113
+ }
114
+
115
+ const recordWithExpire = this.#records?.get(key);
116
+ if (!recordWithExpire) {
117
+ this.#deleteIfUnused();
118
+ return;
119
+ }
120
+
121
+ if (ifOlderThan !== undefined && recordWithExpire.expiresAt - recordWithExpire.ttl >= ifOlderThan) {
122
+ return;
123
+ }
124
+
125
+ this.#records.delete(key);
126
+ this.#recordCount--;
127
+
128
+ this.#context.unregisterForExpiration(recordWithExpire);
129
+
130
+ if (this.#deleteIfUnused()) {
131
+ return;
132
+ }
133
+
134
+ this.#notify("delete", key, recordWithExpire);
135
+ }
136
+
137
+ /**
138
+ * Delete if unused.
139
+ *
140
+ * This is async so we assess whether deletion is appropriate after a batch of updates.
141
+ */
142
+ #deleteIfUnused() {
143
+ if (this.isObserved || this.isDiscovered) {
144
+ return false;
145
+ }
146
+
147
+ if (this.#maybeDeleting) {
148
+ return true;
149
+ }
150
+
151
+ const maybeDelete = async () => {
152
+ this.#maybeDeleting = undefined;
153
+
154
+ if (this.isObserved || this.isDiscovered) {
155
+ return;
156
+ }
157
+
158
+ this.#context.delete(this);
159
+ };
160
+
161
+ this.#maybeDeleting = maybeDelete();
162
+
163
+ return true;
164
+ }
165
+
166
+ /**
167
+ * Notification of observers.
168
+ *
169
+ * This is async so we coalesce changes into a single notification.
170
+ */
171
+ #notify(kind: "update" | "delete", key: string, record: DnssdName.Record) {
172
+ if (this.#changes === undefined) {
173
+ this.#changes = new Map();
174
+ }
175
+ this.#changes.set(key, { kind, record });
176
+
177
+ if (this.#notified) {
178
+ return;
179
+ }
180
+
181
+ const notify = async () => {
182
+ while (this.#changes?.size) {
183
+ const changes: DnssdName.Changes = { name: this };
184
+ for (const { kind, record } of this.#changes.values()) {
185
+ const key: "updated" | "deleted" = `${kind}d`;
186
+ const list = changes[key];
187
+ if (list === undefined) {
188
+ changes[key] = [record];
189
+ } else {
190
+ list.push(record);
191
+ }
192
+ }
193
+ this.#changes.clear();
194
+ await this.emit(changes);
195
+ }
196
+ this.#notified = undefined;
197
+ };
198
+
199
+ this.#notified = notify();
200
+ }
201
+ }
202
+
203
+ function keyOf(record: DnsRecord): string | undefined {
204
+ switch (record.recordType) {
205
+ case DnsRecordType.A:
206
+ case DnsRecordType.AAAA:
207
+ case DnsRecordType.PTR:
208
+ if (typeof record.value === "string") {
209
+ return `${record.recordType} ${record.value}`;
210
+ }
211
+ break;
212
+
213
+ case DnsRecordType.SRV:
214
+ if (typeof record.value === "object") {
215
+ const srv = record.value as SrvRecordValue;
216
+ return `${record.recordType} ${srv.target}:${srv.port}`;
217
+ }
218
+ break;
219
+ }
220
+ }
221
+
222
+ export namespace DnssdName {
223
+ export interface Context {
224
+ delete(name: DnssdName): void;
225
+ registerForExpiration(record: Record): void;
226
+ unregisterForExpiration(record: Record): void;
227
+ }
228
+
229
+ export interface Expiration {
230
+ expiresAt: Timestamp;
231
+ }
232
+
233
+ export interface PointerRecord extends DnsRecord<string>, Expiration {
234
+ recordType: DnsRecordType.PTR;
235
+ }
236
+
237
+ export interface HostRecord extends DnsRecord<string>, Expiration {
238
+ recordType: DnsRecordType.A | DnsRecordType.AAAA;
239
+ }
240
+
241
+ export interface ServiceRecord extends DnsRecord<SrvRecordValue>, Expiration {
242
+ recordType: DnsRecordType.SRV;
243
+ }
244
+
245
+ export type Record = PointerRecord | ServiceRecord | HostRecord;
246
+
247
+ export interface Changes {
248
+ name: DnssdName;
249
+ updated?: Record[];
250
+ deleted?: Record[];
251
+ }
252
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { DnsRecord } from "#codec/DnsCodec.js";
8
+ import { Duration } from "#time/Duration.js";
9
+ import { Time } from "#time/Time.js";
10
+ import { Timestamp } from "#time/Timestamp.js";
11
+ import { Seconds } from "#time/TimeUnit.js";
12
+ import { Entropy } from "#util/Entropy.js";
13
+ import { Lifetime } from "#util/Lifetime.js";
14
+ import { Observable, ObserverGroup } from "#util/Observable.js";
15
+ import { Scheduler } from "#util/Scheduler.js";
16
+ import { DnssdName } from "./DnssdName.js";
17
+ import { QueryMulticaster } from "./DnssdSolicitor.js";
18
+ import { MdnsSocket } from "./MdnsSocket.js";
19
+
20
+ /**
21
+ * Names collected via DNS-SD.
22
+ *
23
+ * TODO - API is designed to support Avahi, Bonjour etc. but current implementation is tied to local MDNS
24
+ */
25
+ export class DnssdNames {
26
+ readonly #socket: MdnsSocket;
27
+ readonly #lifetime: Lifetime;
28
+ readonly #entropy: Entropy;
29
+ readonly #filter?: (record: DnsRecord) => boolean;
30
+ readonly #solicitor: QueryMulticaster;
31
+ readonly #observers = new ObserverGroup();
32
+ readonly #names = new Map<string, DnssdName>();
33
+ readonly #expiration: Scheduler<DnssdName.Record>;
34
+ readonly #discovered = new Observable<[name: DnssdName]>();
35
+ readonly #goodbyeProtectionWindow: Duration;
36
+ readonly #minTtl: Duration;
37
+
38
+ constructor({
39
+ socket,
40
+ lifetime = Lifetime.process,
41
+ entropy,
42
+ filter,
43
+ goodbyeProtectionWindow,
44
+ minTtl: minTtl,
45
+ }: DnssdNames.Context) {
46
+ this.#socket = socket;
47
+ this.#lifetime = lifetime.join("mdns client");
48
+ this.#entropy = entropy;
49
+ this.#filter = filter;
50
+ this.#solicitor = new QueryMulticaster(this);
51
+ this.#goodbyeProtectionWindow = goodbyeProtectionWindow ?? DnssdNames.defaults.goodbyeProtectionWindow;
52
+ this.#minTtl = minTtl ?? DnssdNames.defaults.minTtl;
53
+ this.#observers.on(this.#socket.receipt, this.#handleMessage.bind(this));
54
+
55
+ this.#expiration = new Scheduler({
56
+ name: "expiration scheduler",
57
+ lifetime: this.#lifetime,
58
+ timeOf: a => {
59
+ return a.expiresAt;
60
+ },
61
+ run: record => {
62
+ const discoveryName = this.#names.get(record.name);
63
+ if (discoveryName) {
64
+ discoveryName.deleteRecord(record);
65
+ }
66
+ },
67
+ });
68
+ }
69
+
70
+ #handleMessage(message: MdnsSocket.Message) {
71
+ let goodbyesBefore: undefined | Timestamp;
72
+ for (let record of [...message.answers, ...message.additionalRecords]) {
73
+ if (this.#filter && !this.#filter(record)) {
74
+ continue;
75
+ }
76
+
77
+ const name = this.get(record.name);
78
+ if (record.ttl) {
79
+ if (record.ttl < this.#minTtl) {
80
+ record = { ...record, ttl: this.#minTtl };
81
+ }
82
+ const wasDiscovered = name.isDiscovered;
83
+ name.installRecord(record);
84
+ if (!wasDiscovered && name.isDiscovered) {
85
+ this.#discovered.emit(name);
86
+ }
87
+ } else {
88
+ if (goodbyesBefore === undefined) {
89
+ goodbyesBefore = Timestamp(Time.nowMs - this.#goodbyeProtectionWindow);
90
+ }
91
+ name.deleteRecord(record, goodbyesBefore);
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Test for existence of name.
98
+ */
99
+ has(name: string) {
100
+ name = name.toLowerCase();
101
+ return this.#names.has(name);
102
+ }
103
+
104
+ /**
105
+ * Retrieve the {@link DnssdName} for {@link name}.
106
+ *
107
+ * This will create the name if it does not exist, and if you do not add an observer then it will not automatically
108
+ * delete if there are no records. So if you may not use the record test for existence with {@link has} first.
109
+ */
110
+ get(qname: string): DnssdName {
111
+ let name = this.maybeGet(qname);
112
+ if (name === undefined) {
113
+ name = new DnssdName(qname, this.#nameContext);
114
+ this.#names.set(qname, name);
115
+ }
116
+ return name;
117
+ }
118
+
119
+ /**
120
+ * Retrieve the {@link DnssdName} if known.
121
+ */
122
+ maybeGet(name: string) {
123
+ name = name.toLowerCase();
124
+ return this.#names.get(name);
125
+ }
126
+
127
+ /**
128
+ * Wait for all workers and close all names.
129
+ */
130
+ async close() {
131
+ using _closing = this.#lifetime.closing();
132
+ this.#observers.close();
133
+ await this.#expiration.close();
134
+ for (const name of this.#names.values()) {
135
+ await name.close();
136
+ this.#names.delete(name.qname);
137
+ }
138
+ await this.#solicitor.close();
139
+ }
140
+
141
+ get socket() {
142
+ return this.#socket;
143
+ }
144
+
145
+ /**
146
+ * Emits when a {@link DnssdName} is first discovered.
147
+ */
148
+ get discovered() {
149
+ return this.#discovered;
150
+ }
151
+
152
+ /**
153
+ * Shared solicitor.
154
+ *
155
+ * We offer solicitation in this object so there is not redundant solicitation across interested parties.
156
+ */
157
+ get solicitor() {
158
+ return this.#solicitor;
159
+ }
160
+
161
+ get entropy() {
162
+ return this.#entropy;
163
+ }
164
+
165
+ #nameContext: DnssdName.Context = {
166
+ delete: name => {
167
+ const known = this.#names.get(name.qname);
168
+ if (known === name) {
169
+ this.#names.delete(name.qname);
170
+ }
171
+ },
172
+
173
+ registerForExpiration: record => {
174
+ this.#expiration.add(record);
175
+ },
176
+
177
+ unregisterForExpiration: record => {
178
+ this.#expiration.delete(record);
179
+ },
180
+ };
181
+ }
182
+
183
+ export namespace DnssdNames {
184
+ export interface Context {
185
+ socket: MdnsSocket;
186
+ lifetime?: Lifetime.Owner;
187
+ entropy: Entropy;
188
+ filter?: (record: DnsRecord) => boolean;
189
+
190
+ /**
191
+ * The interval after discovering a record for which we ignore goodbyes.
192
+ *
193
+ * This serves as protection for out-of-order messages when a device expires then broadcasts the same record
194
+ * in a very short amount of time.
195
+ */
196
+ goodbyeProtectionWindow?: Duration;
197
+
198
+ /**
199
+ * Minimum TTL for PTR records.
200
+ */
201
+ minTtl?: Duration;
202
+ }
203
+
204
+ export const defaults = {
205
+ goodbyeProtectionWindow: Seconds(1),
206
+ minTtl: Seconds(15), // This is the value that Apple uses
207
+ };
208
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { DnsMessageType, DnsQuery, DnsRecord, DnsRecordClass, DnsRecordType } from "#codec/DnsCodec.js";
8
+ import { Logger } from "#log/Logger.js";
9
+ import { RetrySchedule } from "#net/RetrySchedule.js";
10
+ import { Time } from "#time/Time.js";
11
+ import { Hours, Millis, Seconds } from "#time/TimeUnit.js";
12
+ import { Abort } from "#util/Abort.js";
13
+ import { BasicMultiplex } from "#util/Multiplex.js";
14
+ import { ObservableValue } from "#util/Observable.js";
15
+ import type { DnssdName } from "./DnssdName.js";
16
+ import type { DnssdNames } from "./DnssdNames.js";
17
+
18
+ const logger = new Logger("DiscoverySolicitor");
19
+
20
+ /**
21
+ * Solicits DNS-SD records for specific names.
22
+ */
23
+ export interface DnssdSolicitor {
24
+ /**
25
+ * Send a single MDNS query for a specific DNS-SD name.
26
+ *
27
+ * Multiple solicitations for the same name are coalesced into the same query using a macrotask.
28
+ */
29
+ solicit(solicitation: DnssdSolicitor.Solicitation): void;
30
+
31
+ /**
32
+ * Send MDNS queries for a specific DNS-SD name using a standard MDNS transmission schedule.
33
+ *
34
+ * The solicitor does not have a notion of "discovery complete", so this function does not return until
35
+ * {@link DnssdSolicitor.Discovery.abort} signals abort (or the solicitor is closed).
36
+ *
37
+ * Multiple simultaneous attempts to complete discovery of the same name will not result in redundant solicitations.
38
+ *
39
+ * If fields in {@link discovery} change their value is used for the next solicitation.
40
+ */
41
+ discover(discovery: DnssdSolicitor.Discovery): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Solicit one or more record types for a name.
46
+ *
47
+ * "Soliciting" consists of broadcasting a query for a DNS-SD name. Groups multiple solicitations in the same
48
+ * macrotask into a single packet.
49
+ */
50
+ export namespace DnssdSolicitor {
51
+ /**
52
+ * Configures solicitation of a single name.
53
+ */
54
+ export interface Solicitation {
55
+ /**
56
+ * The name to solicit.
57
+ */
58
+ name: DnssdName;
59
+
60
+ /**
61
+ * Record types to request.
62
+ */
63
+ recordTypes: DnsRecordType[];
64
+
65
+ /**
66
+ * Additional names to include as known answers.
67
+ */
68
+ associatedNames?: Iterable<DnssdName>;
69
+ }
70
+
71
+ /**
72
+ * Configures repeated solicitation.
73
+ */
74
+ export interface Discovery extends Solicitation {
75
+ /**
76
+ * Terminates discovery.
77
+ */
78
+ abort: AbortSignal;
79
+ }
80
+
81
+ /**
82
+ * Default retry schedule per RFC 6762 (initial delay of 20-120ms. handled separately).
83
+ */
84
+ export const DefaultRetries: RetrySchedule.Configuration = {
85
+ initialInterval: Seconds(1),
86
+ jitterFactor: 0.2,
87
+ backoffFactor: 2,
88
+ maximumInterval: Hours(1),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Concrete implementation of {@link DnssdSolicitor} that sends DNS-SD queries via multicast.
94
+ */
95
+ export class QueryMulticaster implements DnssdSolicitor {
96
+ #names: DnssdNames;
97
+ #schedule: RetrySchedule;
98
+ #abort = new Abort();
99
+ #toSolicit = new Map<DnssdName, DnssdSolicitor.Solicitation>();
100
+ #discovering = new Map<DnssdName, { abort: Abort; finished: Promise<void>; waiting: Set<{}> }>();
101
+ #namesReady = new ObservableValue();
102
+ #workers = new BasicMultiplex();
103
+
104
+ constructor(names: DnssdNames, retries?: RetrySchedule.Configuration) {
105
+ this.#names = names;
106
+ this.#schedule = new RetrySchedule(
107
+ names.entropy,
108
+ RetrySchedule.Configuration(DnssdSolicitor.DefaultRetries, retries),
109
+ );
110
+ this.#workers.add(this.#emitSolicitations());
111
+ }
112
+
113
+ solicit(solicitation: DnssdSolicitor.Solicitation) {
114
+ if (this.#abort.aborted) {
115
+ return;
116
+ }
117
+ const entry = this.#toSolicit.get(solicitation.name);
118
+ if (entry === undefined) {
119
+ this.#toSolicit.set(solicitation.name, { ...solicitation });
120
+ } else {
121
+ entry.recordTypes = [...new Set([...entry.recordTypes, ...solicitation.recordTypes])];
122
+ if (solicitation.associatedNames) {
123
+ if (!entry.associatedNames) {
124
+ entry.associatedNames = solicitation.associatedNames;
125
+ } else {
126
+ entry.associatedNames = [...new Set([...entry.associatedNames, ...solicitation.associatedNames])];
127
+ }
128
+ }
129
+ }
130
+ this.#namesReady.emit(true);
131
+ }
132
+
133
+ async discover(discovery: DnssdSolicitor.Discovery) {
134
+ let active = this.#discovering.get(discovery.name);
135
+ if (active) {
136
+ active.waiting.add(discovery);
137
+ } else {
138
+ // This abort is different from the input abort because we only abort when the input aborts if nobody else
139
+ // is waiting on discovery of the same name
140
+ const abort = new Abort({ abort: this.#abort });
141
+ active = {
142
+ abort,
143
+ finished: this.#discover(discovery, abort),
144
+ waiting: new Set([discovery]),
145
+ };
146
+ this.#discovering.set(discovery.name, active);
147
+ }
148
+
149
+ try {
150
+ await Abort.race(discovery.abort, active.finished);
151
+ } finally {
152
+ active.waiting.delete(discovery);
153
+ if (active.waiting.size === 0) {
154
+ active.abort();
155
+ this.#discovering.delete(discovery.name);
156
+ }
157
+ }
158
+ }
159
+
160
+ async #discover(solicitation: DnssdSolicitor.Solicitation, abort: Abort) {
161
+ // Wait initially 20 - 120 ms per RFC 6762
162
+ let timeout = Millis.floor(Millis(20 + 100 * (this.#names.entropy.randomUint32 / Math.pow(2, 32))));
163
+
164
+ for (const nextTimeout of this.#schedule) {
165
+ using delay = new Abort({ abort, timeout });
166
+
167
+ await delay;
168
+ if (abort.aborted) {
169
+ break;
170
+ }
171
+
172
+ timeout = nextTimeout;
173
+
174
+ this.solicit(solicitation);
175
+ }
176
+ }
177
+
178
+ async close() {
179
+ this.#abort();
180
+ await this.#workers;
181
+ }
182
+
183
+ async #emitSolicitations() {
184
+ while (true) {
185
+ // Wait for names to solicit
186
+ await this.#abort.race(this.#namesReady);
187
+ if (this.#abort.aborted) {
188
+ return;
189
+ }
190
+
191
+ // Delay using a macrotask so we coalesce names
192
+ await this.#abort.race(Time.sleep("discovery solicitor delay", 0));
193
+ if (this.#abort.aborted) {
194
+ return;
195
+ }
196
+
197
+ // Gather names we will solicit in this iteration
198
+ const entries = [...this.#toSolicit.values()];
199
+ this.#namesReady.value = false;
200
+ this.#toSolicit.clear();
201
+
202
+ // Create sets for queries and known answers
203
+ const queries = Array<DnsQuery>();
204
+ const answers = Array<DnsRecord>();
205
+
206
+ for (const {
207
+ name: { qname: name, records },
208
+ recordTypes,
209
+ } of entries) {
210
+ for (const recordType of recordTypes) {
211
+ queries.push({ name, recordClass: DnsRecordClass.IN, recordType });
212
+ }
213
+
214
+ answers.push(...records);
215
+ }
216
+
217
+ // Send the message
218
+ try {
219
+ await this.#abort.race(
220
+ this.#names.socket.send({
221
+ messageType: DnsMessageType.Query,
222
+ queries,
223
+ answers,
224
+ }),
225
+ );
226
+ } catch (e) {
227
+ logger.error("Unhandled error soliciting DNS-SD names:", e);
228
+ }
229
+ }
230
+ }
231
+ }